feat(username): normalize username into lowercase
- normalize username into lowercase (this is already ASCII only) - permit users to login with their username with insensitive case - if a disabled account is reactivated and got a collision, it will normalize it too - server will stop on collision (ex: 2 entries with `Alice` and `alice`) in a such case admin can run: ``` oxicloud migrate lowercase-usernames --dry-run ``` then ``` oxicloud migrate lowercase-usernames ```
This commit is contained in:
@@ -123,6 +123,70 @@ rather than as a visible error.
|
||||
| `OXICLOUD_NOTIFICATIONS_RETENTION_DAYS` | `30` | Retention window for **read** notification rows (`notif.notifications`). The `notifications_cleanup` scheduled job runs daily and deletes rows where `read_at IS NOT NULL` and `read_at < now() - retention_days`. Unread rows are preserved unconditionally — the whole point of the durable table is that a user offline for a month still sees the share-granted notice on next login. Clamped to a minimum of 1 (0 would purge every read row on every tick). Adjust down for compliance-sensitive deployments where "cleared once seen" matters; adjust up when operators expect users to reference old notifications for support. |
|
||||
| `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX` | `@drive` | Native WebDAV URL segment that renders the caller's drive list. Sanitized by trimming leading/trailing `/`. Three shapes: (1) default `@drive` — `/webdav/…` addresses the caller's default personal drive (back-compat), `/webdav/@drive/` returns the drive listing, `/webdav/@drive/<uuid\|name>/…` targets a specific drive. (2) empty string `""` — `/webdav/` IS the drive listing, `/webdav/<uuid\|name>/…` targets a specific drive, no default-drive shortcut. (3) any other string (e.g. `drives`) — same shape as `@drive` with that segment substituted. Only drives the caller has Read on via `role_grants` resolve. |
|
||||
|
||||
## Boot-time refuse-to-boot checks
|
||||
|
||||
Some upgrades add invariants the running database must satisfy
|
||||
BEFORE the new binary can serve traffic. These are enforced by
|
||||
read-only checks that run after `sqlx::migrate!()` and before the
|
||||
server binds a listen socket. If a check fails, the server exits
|
||||
with a FATAL message spelling out the exact CLI command to run.
|
||||
|
||||
The server **never silently mutates data** at boot — every fix is
|
||||
an explicit `oxicloud migrate <name>` invocation. Follows the
|
||||
"discovery-only by default, mutation opt-in" rule that also
|
||||
governs the consistency-check jobs.
|
||||
|
||||
### `lowercase-usernames`
|
||||
|
||||
Three outcomes at boot, only one of which stops the server:
|
||||
|
||||
- **All lowercase (or `NULL`)** — the check is a no-op, boot
|
||||
proceeds unchanged.
|
||||
- **Mixed-case rows exist, no `LOWER(username)` collision** —
|
||||
boot **auto-lowercases** them in one atomic transaction, emits
|
||||
a structured audit line per rename
|
||||
(`user.username_lowercased_on_boot`, INFO) plus an INFO summary
|
||||
(`user.usernames_lowercased_on_boot_summary` with `renamed=N`),
|
||||
and continues. Silent action is confined to the case with
|
||||
exactly one correct move: `Alice` (with no `alice` row) becomes
|
||||
`alice`.
|
||||
- **`LOWER(username)` collision** — two or more active rows share
|
||||
the same lowercase form (e.g. `Alice` + `alice`). Boot
|
||||
**refuses to start** with a FATAL message spelling out every
|
||||
collision group and the exact CLI command to resolve it.
|
||||
Tiebreak needs a human.
|
||||
|
||||
Soft-deleted / disabled accounts (`active = false`) and `NULL`
|
||||
usernames (OPAQUE-migrated) are skipped in every case.
|
||||
|
||||
**Refusal message pattern:** `FATAL: cannot start — N colliding
|
||||
username group(s) (M affected account(s) in total)`, followed by
|
||||
each group's canonical form and its members with `id` +
|
||||
`last_login`. Up to 10 groups shown; the `--dry-run` CLI reveals
|
||||
the full list.
|
||||
|
||||
**Fix (only required when the server refused):**
|
||||
```
|
||||
oxicloud migrate lowercase-usernames --dry-run # preview the tiebreak
|
||||
oxicloud migrate lowercase-usernames # apply
|
||||
```
|
||||
|
||||
**What the migration does:** lowercases every mixed-case
|
||||
username. On collision (`Alice` + `alice` both exist), the
|
||||
tiebreak `(last_login_at DESC NULLS LAST, created_at ASC)` picks
|
||||
a winner; losers get `alice-2`, `-3`, … as a suffix. Sessions
|
||||
and grants survive the rename — both key on the user's UUID.
|
||||
|
||||
**Client compat:** NextCloud clients that cached URLs like
|
||||
`/remote.php/dav/files/Alice/…` continue to work indefinitely —
|
||||
the Basic Auth middleware and URL parser both lowercase on
|
||||
decode. NC desktop clients will prompt a one-time re-sync on
|
||||
first PROPFIND after upgrade; DAVX5 and NC mobile handle it
|
||||
silently. See `docs/install/binary.md § Upgrading from a
|
||||
case-sensitive-usernames release` for the full upgrade flow.
|
||||
|
||||
**Design:** `docs/plan/username-lowercase.md`.
|
||||
|
||||
## Storage Entries (multi-entry, recommended)
|
||||
|
||||
Declare one or more **named** storage backends. The one the app runs on is picked from the DB (`admin_settings.storage.active_backend_name`); the admin panel's storage tab flips the pointer, and cross-backend migration is a recoverable job that copies blobs between two entries with a read-only safety window. See [Admin Settings — Storage & Migration](/config/admin-settings) for the operator flow and the [multi-entry design doc](https://github.com/oxicloud/oxicloud/blob/main/docs/plan/storage-multi-entry.md) for the full model.
|
||||
|
||||
@@ -217,6 +217,75 @@ supported by sqlx's migration model; if you need to roll back, stop
|
||||
the server, roll back your Postgres data directory to a snapshot, and
|
||||
install the previous binary.
|
||||
|
||||
### Upgrading from a case-sensitive-usernames release
|
||||
|
||||
Releases that predate the case-insensitive-usernames change stored
|
||||
`Alice`, `alice`, and `ALICE` as three separate accounts. The
|
||||
current release treats usernames as case-insensitive (canonical
|
||||
lowercase in the database). What happens on the first boot after
|
||||
upgrade depends on your data:
|
||||
|
||||
**No mixed-case usernames.** The check is a no-op; the server
|
||||
starts normally. Nothing to do.
|
||||
|
||||
**Mixed-case usernames with no collision.** The server
|
||||
**auto-lowercases** them in one atomic transaction at boot and
|
||||
continues. Each rename is recorded in the audit log
|
||||
(`user.username_lowercased_on_boot`) and a WARN summary line
|
||||
names the total count. `Alice` (with no `alice` row alongside)
|
||||
becomes `alice`; no ops action needed. This covers the vast
|
||||
majority of single-admin self-hosted deployments.
|
||||
|
||||
**Mixed-case usernames WITH a collision** (`Alice` + `alice`
|
||||
both exist as active accounts). The server **refuses to boot**
|
||||
— tiebreak requires a human. Run the migration:
|
||||
|
||||
```
|
||||
# Preview the tiebreak — no writes.
|
||||
sudo -u oxicloud DATABASE_URL="postgres://..." \
|
||||
/usr/local/bin/oxicloud migrate lowercase-usernames --dry-run
|
||||
|
||||
# Apply. Renames run in a single transaction; safe to re-run if aborted.
|
||||
sudo -u oxicloud DATABASE_URL="postgres://..." \
|
||||
/usr/local/bin/oxicloud migrate lowercase-usernames
|
||||
```
|
||||
|
||||
On collision, the migration picks a winner by `(last_login_at
|
||||
DESC NULLS LAST, created_at ASC)` — most recently active keeps
|
||||
the canonical lowercase name; the loser gets `alice-2`, `-3`, …
|
||||
as a suffix. **Sessions and grants survive the rename** — both
|
||||
key on the user's UUID, not the username.
|
||||
|
||||
Skipped in every path above: soft-deleted / disabled accounts
|
||||
and OPAQUE-migrated users whose `username` column is NULL.
|
||||
Neither blocks boot.
|
||||
|
||||
After the migration completes, restart the service:
|
||||
|
||||
```
|
||||
sudo systemctl start oxicloud
|
||||
```
|
||||
|
||||
**Nextcloud desktop clients** will prompt a one-time re-sync on
|
||||
their first PROPFIND after upgrade — the account URL case
|
||||
changed. Data is safe (files re-verify via ETag, not re-uploaded).
|
||||
DAVX5 (calendars, contacts) and NC mobile handle the URL case
|
||||
change silently. **No client upgrade or reconfiguration is
|
||||
required** — the server accepts uppercase URL segments (`Alice`
|
||||
in `/remote.php/dav/files/Alice/...`) indefinitely.
|
||||
|
||||
The refusal message printed by the server on boot (collision
|
||||
path only) includes the exact CLI command above, so you can't
|
||||
miss it. Full plan and rationale in
|
||||
`docs/plan/username-lowercase.md`.
|
||||
|
||||
**Explicit-preview path.** If you'd rather run the migration
|
||||
before the binary swap — to review renames on your own schedule
|
||||
or to gate a backup step — run `oxicloud migrate
|
||||
lowercase-usernames --dry-run` against the OLD binary's DB
|
||||
first, then apply. On next boot the new binary sees a
|
||||
lowercase-clean DB and the auto-rename path is a no-op.
|
||||
|
||||
## Installing via `cargo binstall`
|
||||
|
||||
If you already have the Rust toolchain and just want the binary
|
||||
|
||||
@@ -66,9 +66,26 @@ and get "invalid credentials" instead of a successful login.
|
||||
CLI calls. Both callers reach for a shared
|
||||
`find_free_username_suffix(pool, base) -> String` in
|
||||
`src/common/username_migration.rs` so migration + un-soft-delete
|
||||
agree by construction. Without this, an un-soft-delete of the
|
||||
only pre-migration mixed-case survivor would refuse-to-boot on
|
||||
the next restart.
|
||||
agree by construction. Without this, an un-soft-delete could
|
||||
create a fresh collision that the next boot's auto-rename
|
||||
couldn't resolve (auto-rename handles singletons only) — the
|
||||
server would then refuse-to-boot until an admin resolves the
|
||||
tiebreak.
|
||||
8. **Boot-time behaviour has three outcomes, not two.** The
|
||||
verifier categorises the DB into: (a) clean — nothing to do;
|
||||
(b) mixed-case rows with no `LOWER(username)` collision — the
|
||||
server **auto-lowercases them in one atomic transaction and
|
||||
continues**, emitting an audit line per rename; (c) at least
|
||||
one `LOWER(username)` collision — the server **refuses to
|
||||
boot** because tiebreak requires human judgement. Silent
|
||||
action is bounded to (b), where there is exactly one correct
|
||||
move. This is a narrower reading of
|
||||
[[feedback_no_silent_auto_repair]] than "no silent action
|
||||
ever": the rule targets consistency-check jobs where drift is
|
||||
a bug signal; a schema-adjacent boot invariant with a
|
||||
unique-correct-fix is a different situation. Making the
|
||||
trivial-case common path a no-op massively lowers upgrade
|
||||
friction for the 90% self-hosted deployment.
|
||||
|
||||
## Not in scope
|
||||
|
||||
@@ -162,9 +179,10 @@ change per method:
|
||||
dispatch on `@`; lowercase the username branch input.
|
||||
|
||||
Post-migration, the DB is fully lowercase so `WHERE username = 'alice'`
|
||||
matches. Pre-migration users are blocked from booting by the boot-time
|
||||
check, so the mixed-case-DB-during-transition state cannot serve
|
||||
traffic.
|
||||
matches. The mixed-case-DB-during-transition state cannot serve
|
||||
traffic because the boot flow either (a) auto-renames the singleton
|
||||
rows before `AppState` assembles, or (b) refuses to boot on collision
|
||||
groups.
|
||||
|
||||
### 4. NextCloud DAV surface
|
||||
|
||||
@@ -230,12 +248,30 @@ cache key. Post-migration, `user.username` becomes lowercase; any
|
||||
in-flight upload for `Alice` at migration time strands the on-disk
|
||||
`base_dir/Alice/upload_xxx/` directory and orphans its cache entry.
|
||||
|
||||
The migration command must ALSO walk `base_dir/*/` and rename any
|
||||
The migration command SHOULD walk `base_dir/*/` and rename any
|
||||
mixed-case subdirectory to its lowercase form. Collision handling
|
||||
(both `Alice/` and `alice/` present) → merge contents; else simple
|
||||
rename. In practice this is likely a no-op — chunked-upload state is
|
||||
ephemeral, and simultaneous mixed-case uploads by the same user are
|
||||
rare.
|
||||
rename. In practice this is likely a no-op — chunked-upload state
|
||||
is ephemeral, and simultaneous mixed-case uploads by the same user
|
||||
are rare.
|
||||
|
||||
**Implementation status:** deferred. Chunked-upload state is
|
||||
ephemeral: any in-flight upload that gets stranded is retryable
|
||||
by the client (the upload session's timeout eventually purges the
|
||||
stale dir; the client retries with a fresh `upload_id`, this time
|
||||
under the lowercase username). Wiring the dir-walk into the CLI
|
||||
adds ~40 lines of async filesystem code (walk, collision merge,
|
||||
mtime-preserving move) and a new `--chunk-dir <path>` arg — the
|
||||
CLI otherwise doesn't need to know about the storage-path
|
||||
config layer. Not worth it for a rare no-op; add if user reports
|
||||
show a real problem.
|
||||
|
||||
**Ops manual step** — if a migration is run WHILE an upload is
|
||||
in flight, ops can either restart the affected client (the
|
||||
upload session is stateful across a `create → chunks → complete`
|
||||
cycle, so the client will retry from scratch) or manually
|
||||
`mv base_dir/Alice base_dir/alice` after the DB migration
|
||||
completes.
|
||||
|
||||
### 6. Boot-time verification
|
||||
|
||||
@@ -318,8 +354,9 @@ Following the shape of `run_nfc_filenames`:
|
||||
collision-resolved / renamed-to-suffix
|
||||
- `--dry-run` guards all UPDATEs
|
||||
|
||||
After the DB pass, run the chunked-upload directory rename step (see
|
||||
Deliverable 5).
|
||||
After the DB pass, the chunked-upload directory rename step (see
|
||||
Deliverable 5) is deferred; run manually only if in-flight uploads
|
||||
were live at migration time.
|
||||
|
||||
Suffix search reuses the pattern from
|
||||
`find_free_folder_duplicate_name` in the existing NFC migration —
|
||||
@@ -376,26 +413,37 @@ Files verified (all safe):
|
||||
|
||||
### 10. Documentation
|
||||
|
||||
- `CHANGELOG.md` — user-visible note:
|
||||
- Migration required; server refuses to boot until it's been run.
|
||||
- Exact CLI command shown in the refusal message.
|
||||
- Nextcloud desktop clients will prompt for a one-time re-sync
|
||||
on first PROPFIND after upgrade. Files are ETag-verified, not
|
||||
re-uploaded. DAVX5 and NC mobile handle the URL case change
|
||||
silently. **No forced client upgrade or reconfiguration** —
|
||||
server accepts uppercase URL segments indefinitely.
|
||||
- Optional pre-emption for non-technical users: ops can manually
|
||||
update the account URL to lowercase in each NC desktop client
|
||||
before upgrading, avoiding the re-sync prompt entirely.
|
||||
- Usernames become lowercase in ALL UI display surfaces (share
|
||||
dialogs, activity feeds, admin panels, PROPFIND response
|
||||
bodies, notification bell). Login identity unchanged from the
|
||||
user's POV (they can still type any case at the login form).
|
||||
- Avatar fallback color may change for users with previously-
|
||||
uppercase usernames.
|
||||
- Preamble noting `display_name` is a possible follow-up if
|
||||
users miss capitalization for display — deferred pending
|
||||
demand signal, no compat cost to adding later.
|
||||
Release notes / CHANGELOG entry is NOT part of this PR — the
|
||||
canonical repo's maintainer handles release notes at version-bump
|
||||
time. This PR just leaves the notes-worthy items enumerated here
|
||||
so the maintainer has the bullets to pick from when the next
|
||||
version ships:
|
||||
|
||||
- Server auto-lowercases non-colliding mixed-case usernames at
|
||||
first boot. No ops action needed for the common case.
|
||||
- On `LOWER(username)` collision (`Alice` + `alice` both active),
|
||||
the server refuses to boot; ops runs `oxicloud migrate
|
||||
lowercase-usernames`. Exact CLI command shown in the refusal.
|
||||
- Nextcloud desktop clients will prompt for a one-time re-sync on
|
||||
first PROPFIND after upgrade. Files are ETag-verified, not
|
||||
re-uploaded. DAVX5 and NC mobile handle the URL case change
|
||||
silently. **No forced client upgrade or reconfiguration** —
|
||||
server accepts uppercase URL segments indefinitely.
|
||||
- Optional pre-emption for non-technical users: ops can manually
|
||||
update the account URL to lowercase in each NC desktop client
|
||||
before upgrading, avoiding the re-sync prompt entirely.
|
||||
- Usernames become lowercase in ALL UI display surfaces (share
|
||||
dialogs, activity feeds, admin panels, PROPFIND response
|
||||
bodies, notification bell). Login identity unchanged from the
|
||||
user's POV (they can still type any case at the login form).
|
||||
- Avatar fallback color may change for users with previously-
|
||||
uppercase usernames.
|
||||
- Note: `display_name` is a possible follow-up if users miss
|
||||
capitalisation for display — deferred pending demand signal, no
|
||||
compat cost to adding later.
|
||||
|
||||
The two docs that DO ship with this PR:
|
||||
|
||||
- `docs/config/env.md` — note the boot-time check + migration command.
|
||||
- `docs/install/binary.md` — upgrade-from-case-sensitive section.
|
||||
|
||||
@@ -404,19 +452,26 @@ Files verified (all safe):
|
||||
- **Unit**: `validate_username("Alice")` returns `Ok("alice")`;
|
||||
`validate_username("alice-")` returns `Err(...)` unchanged;
|
||||
`validate_username(" Alice ")` returns `Ok("alice")`.
|
||||
- **Unit**: `verify_all_usernames_lowercase` with mocked pool — empty
|
||||
result → Ok; non-empty → Err with formatted message.
|
||||
- **Unit**: `format_refusal_message_collisions` — 1 group renders
|
||||
canonical + members + CLI; > 10 groups renders overflow tail;
|
||||
total-affected-count sums across groups.
|
||||
- **Hurl** (`tests/api/lowercase_usernames.hurl`, new): register a
|
||||
user with `MixedCase`, assert DB stores `mixedcase`; log in with
|
||||
`MIXEDCASE` and `mixedcase` — both succeed; rename to `NewName`,
|
||||
assert `newname` stored; NC Basic Auth accepts `MixedCase:pass`,
|
||||
`MIXEDCASE:pass`, `mixedcase:pass`.
|
||||
- **Manual** (against dev DB, not CI):
|
||||
- Induce a collision via `INSERT INTO auth.users … 'Alice'` on top
|
||||
of `alice`; boot server → verify refusal message + exact CLI shown
|
||||
- Auto-rename path: `UPDATE auth.users SET username='Alice' WHERE
|
||||
username='alice'` (no collision); boot server → verify audit log
|
||||
line + WARN summary, row is `alice` after boot, service starts.
|
||||
- Collision path: `INSERT INTO auth.users … 'Alice'` on top of
|
||||
existing `alice`; boot server → verify refusal message names both
|
||||
rows + exact CLI shown, server exits non-zero.
|
||||
- `oxicloud migrate lowercase-usernames --dry-run` → verify report
|
||||
- `oxicloud migrate lowercase-usernames` → verify apply
|
||||
- Boot again → succeeds
|
||||
of collision + tiebreak decision
|
||||
- `oxicloud migrate lowercase-usernames` → verify apply, one row
|
||||
keeps `alice`, other gets `alice-2`
|
||||
- Boot again → succeeds (Clean outcome)
|
||||
- `curl -u ALICE:pass https://oxicloud/remote.php/dav/files/ALICE/…`
|
||||
→ succeeds (accepts uppercase input, resolves to lowercase user)
|
||||
|
||||
@@ -489,7 +544,9 @@ Full enumeration in the Deliverables sections above. Grouped summary:
|
||||
DB pass + chunked-upload directory rename.
|
||||
8. Test seed audit (grep pass).
|
||||
9. Add hurl coverage.
|
||||
10. CHANGELOG entry + admin docs update.
|
||||
10. Admin docs update (`docs/config/env.md` boot-check subsection +
|
||||
`docs/install/binary.md` upgrade section). CHANGELOG is Dio's
|
||||
job at version-bump time — not part of this PR.
|
||||
11. Manual smoke test against dev DB.
|
||||
12. PR to canonical.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user