Files
hub/docs/drives.md
T
2026-10-14 02:02:31 -05:00

5.1 KiB

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. 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 <a> 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), 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}]. Not implemented in drives.py.
GET /files/<drive> List files: [{id, name, size, mime, created}]. Not implemented in drives.py.
POST /files/<drive>/<filename> Multipart upload (field file) in a single request. The URL tail — <path:filename>, slashes allowed — becomes the stored filename; the part's own name is the fallback. Returns {fileId}.
GET /files/<drive>/<filename> 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/<drive>/<filename> Delete the record, then the blob. Returns the removed absolute disk path (abs_path).

Only resolve_file stamps accessed_at; get_metadata and delete_file never touch it.

Frontend interface

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, path) — multipart-POSTs the whole file to /files/<driveName>/<path>, 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/<drive>/<path:filename>.
  • 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 (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, 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

  • 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.
  • 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.