diff --git a/backend/src/drives.py b/backend/src/drives.py index 5fefde5..ccc3106 100644 --- a/backend/src/drives.py +++ b/backend/src/drives.py @@ -27,7 +27,7 @@ async def upload_file(user_id, drive_name, filename, stream, mime_type=None): drive = await db.Drive.create(user_id=user_id, name=drive_name) # Check collision - if await db.Drive.get_or_none(drive_id=drive.id, filename=filename): + if await db.DriveFile.get_or_none(drive_id=drive.id, filename=filename): raise Exception("Duplicated file") # 2. Prepare disk path @@ -82,7 +82,7 @@ async def delete_file(file_id): return abs_path async def resolve_file(user_id, drive_name, filename): - """Resolve file to absolute disk path after permission check. Returns (path, mime).""" + """Resolve file to absolute disk path after permission check. Returns (abs_path, file.id).""" # Eager load drive and verify ownership drive = await db.Drive.get_or_none(user_id=user_id, name=drive_name) if drive.user_id != user_id: diff --git a/docs/drives.md b/docs/drives.md index 134b7d9..48ead71 100644 --- a/docs/drives.md +++ b/docs/drives.md @@ -1,27 +1,29 @@ # Drives -Per-user file storage: a **drive** is a named container owned by a user, and each **file** is a database record plus a blob on disk. Everything is addressed by *drive name + filename* — the random file id never appears in URLs. Drives bypass the WebSocket action protocol entirely: all endpoints are plain REST (routes in `backend/src/server/web.py`, logic in `backend/src/drives.py`), so downloads can be plain `` links. +Per-user file storage: a **drive** is a named container owned by a user, and each **file** is a database record plus a blob on disk. Client-facing routes are addressed by *drive name + filename* — the random file id never appears in URLs — though several `drives.py` helpers below are id-addressed internally. Drives bypass the WebSocket action protocol entirely: all endpoints are plain REST (routes in `backend/src/server/web.py`, logic in `backend/src/drives.py`), so downloads can be plain `` links. ## Logical model - **Drive** — user-scoped, addressed by name. Created implicitly on first upload to a new name; there is no create/rename/delete-drive endpoint. - **File** — readable `filename`, mime type, size, `created`/`accessed` timestamps. - **Folders are virtual** — a filename may contain slashes (`docs/api/readme.md`); the Drives view splits names to render a tree, but storage stays flat. -- **Identity** — `DriveFile.id` is a random hex string (`secrets.token_hex(8)`) that doubles as the on-disk filename, so user-facing names never touch the filesystem. +- **Identity** — `DriveFile.id` is a random hex string (`secrets.token_hex(8)`, 16 hex chars) that doubles as the on-disk filename, so user-facing names never touch the filesystem. ## Backend interface Download links can't set headers, so the GET/DELETE file routes additionally accept the token as a `?token=` query parameter. +Client-facing file routes are drive+filename-addressed, but the `drives.py` helpers behind them are mixed: `upload_file` accepts drive+filename directly, while `get_metadata(file_id)` and `delete_file(file_id)` are id-addressed, so their routes must first resolve filename → id (via the drive's file records) before calling them. The two listing routes are not implemented in `drives.py` at all — their logic lives elsewhere. + | Route | Behavior | | --- | --- | -| `GET /files` | List the user's drives: `[{id, name}]` | -| `GET /files/` | List files: `[{id, name, size, mime, created}]` | +| `GET /files` | List the user's drives: `[{id, name}]`. Not implemented in `drives.py`. | +| `GET /files/` | List files: `[{id, name, size, mime, created}]`. Not implemented in `drives.py`. | | `POST /files//` | Multipart upload (field `file`) in a single request. The URL tail — ``, slashes allowed — becomes the stored filename; the part's own name is the fallback. Returns `{fileId}`. | -| `GET /files//` | Serve the bytes with their stored mime type, or return `{id, name, size, mime, created, accessed}` when `?metadata` is present. | -| `DELETE /files//` | Delete the record, then the blob. Returns `{fileId}`. | +| `GET /files//` | Serve the bytes, with the mime type looked up from the file record (not from `resolve_file`), or return `{id, name, size, mime, created, accessed}` when `?metadata` is present. | +| `DELETE /files//` | Delete the record, then the blob. Returns the removed absolute disk path (`abs_path`). | -Every resolve (download, metadata, delete) stamps `accessed_at`. +Only `resolve_file` stamps `accessed_at`; `get_metadata` and `delete_file` never touch it. ## Frontend interface @@ -29,20 +31,20 @@ In `frontend/src/backend.js`: - `webFetch(path, options)` — generic REST helper: sets `Authorization: Bearer` (waiting for login if needed), parses JSON, throws on non-2xx. Downloads bypass it. - `getDrives()`, `getDriveFiles(driveName)`, `deleteDriveFile(driveName, filename)` -- `uploadDriveFile(driveName, file)` — multipart POST of the whole file. +- `uploadDriveFile(driveName, file, path)` — multipart-POSTs the whole file to `/files//`, where `path` is the destination filename including any virtual folder prefix (`docs/api/readme.md`). The composite path belongs in the URL, since the upload route only exists at `/files//`. - `driveFileUrl(driveName, filename)` — GET URL carrying `?token=`, used for download anchors and for opening a file in a tab. -`frontend/src/views/Drives.vue` lazy-loads a drive's files on first expand, rebuilds the folder tree from the flat file list on every load (open folders live on the drive object, so they survive reloads), uploads through a file picker, and deletes with confirmation. +`frontend/src/views/Drives.vue` lazy-loads a drive's files on first expand, rebuilds the folder tree from the flat file list on every load (open folders live on the drive object, so they survive reloads), uploads through a file picker (building the target path from the picked folder and passing it as `uploadDriveFile`'s third argument), and deletes with confirmation. ## Implementation notes -- Models in `backend/src/database.py` (migration `0002_auto_20260921_1840`): `Drive(id, user_id, name, created_at)` and `DriveFile(id char(64) pk, drive_id, filename, mime_type, size_bytes, accessed_at, created_at)`, table `drive_files`. Ownership rides the `User → Drive → File` chain and is checked before any disk access. +- Models in `backend/src/database.py` (migration `0002_auto_20260921_1840`): `Drive(id, user_id, name, created_at)` and `DriveFile(id, drive_id, filename, mime_type, size_bytes, accessed_at, created_at)`, table `drive_files`. The `id` is a 16-char hex string, not `char(64)`. Ownership rides the `User → Drive → File` chain; it is checked in `resolve_file` before disk access, but `delete_file` and `get_metadata` take a bare `file_id` and perform no ownership check of their own — their routes must guard them. - Disk layout: `{data_dir}/drives/{drive_id}/{file_id}` — keyed by drive id, not by user or name. `init_drives(data_directory)`, called from `main()`, creates the root. - Upload streams to disk in 8 KiB chunks and only then writes the DB record; delete removes the record first, then the blob. ## Known issues -- `uploadDriveFile` POSTs to `/files/`, where no POST route exists, and drops the folder-path argument `Drives.vue` passes it — UI uploads fail, and folder-targeted upload is not wired up. - Drives are only created implicitly, and the view only uploads into existing drives, so the first drive cannot be created from the frontend. - Uploads are meant to refuse a filename already used in the drive, but the check queries the `Drive` model with `DriveFile`'s fields, so it errors instead of detecting duplicates. -- `resolve_file` dereferences the drive without a None check, so a nonexistent drive yields a 500 instead of a 404. \ No newline at end of file +- `resolve_file` dereferences the drive without a None check, so a nonexistent drive yields a 500 instead of a 404. +- `delete_file` and `get_metadata` take a bare `file_id` with no ownership check; if their routes don't resolve and authorize the file against the owning user, any authenticated user could delete or inspect another user's file by id. \ No newline at end of file diff --git a/frontend/src/backend.js b/frontend/src/backend.js index 95b6a04..aefd0a7 100644 --- a/frontend/src/backend.js +++ b/frontend/src/backend.js @@ -379,6 +379,8 @@ export function invalidateCache({ chats=false, agents=false } = {}) { // Drive endpoints are plain HTTP routes instead of WebSocket actions, // so they authenticate with the bearer token in the Authorization header. +// TODO: Next time these functions need updating, move them directly to views/Drives.vue + export function getDrives () { return webFetch('/files'); } @@ -387,10 +389,10 @@ export function getDriveFiles (driveName) { return webFetch(`/files/${driveName}`); } -export function uploadDriveFile (driveName, file) { +export function uploadDriveFile (driveName, file, path) { const body = new FormData(); body.append('file', file, file.name); - return webFetch(`/files/${driveName}`, { + return webFetch(`/files/${driveName}/${path}`, { method: 'POST', body, });