Compare commits
2
Commits
4e7be79926
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea938a1248 | ||
|
|
0d79b1f706 |
No files matched your search
@@ -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)
|
drive = await db.Drive.create(user_id=user_id, name=drive_name)
|
||||||
|
|
||||||
# Check collision
|
# 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")
|
raise Exception("Duplicated file")
|
||||||
|
|
||||||
# 2. Prepare disk path
|
# 2. Prepare disk path
|
||||||
@@ -82,7 +82,7 @@ async def delete_file(file_id):
|
|||||||
return abs_path
|
return abs_path
|
||||||
|
|
||||||
async def resolve_file(user_id, drive_name, filename):
|
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
|
# Eager load drive and verify ownership
|
||||||
drive = await db.Drive.get_or_none(user_id=user_id, name=drive_name)
|
drive = await db.Drive.get_or_none(user_id=user_id, name=drive_name)
|
||||||
if drive.user_id != user_id:
|
if drive.user_id != user_id:
|
||||||
|
|||||||
@@ -68,14 +68,54 @@ def register(server, app, auth_manager):
|
|||||||
stop_listening = app.on_event(on_event)
|
stop_listening = app.on_event(on_event)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
req = json.loads(await websocket.receive())
|
asyncio.create_task(handle_request(
|
||||||
if not 'action' in req:
|
app, user_id, session_connections, send, await websocket.receive()
|
||||||
|
))
|
||||||
|
|
||||||
|
finally:
|
||||||
|
print("User logged out:", user_id)
|
||||||
|
sender_task.cancel()
|
||||||
|
if ws_task in app.ws_tasks:
|
||||||
|
app.ws_tasks.remove(ws_task)
|
||||||
|
# Unregister the event listener: the send closure would
|
||||||
|
# otherwise keep queueing events for a dead socket forever
|
||||||
|
if stop_listening:
|
||||||
|
stop_listening()
|
||||||
|
# All remotes served by this session are disconnected with it.
|
||||||
|
# disconnectRemote is idempotent, so remotes that were already
|
||||||
|
# disconnected manually (possibly from another session) are safe.
|
||||||
|
for remotename in list(session_connections):
|
||||||
|
app.disconnectRemote(user_id, remotename)
|
||||||
|
app.remove_user_connection(user_id, send_queue)
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
async def handle_request(app, user_id, session_connections, send, req):
|
||||||
|
try:
|
||||||
|
req = json.loads(req)
|
||||||
|
await process_request(app, user_id, session_connections, send, req)
|
||||||
|
|
||||||
|
except Exception as err:
|
||||||
|
action = req.get('action') if isinstance(req, dict) else None
|
||||||
|
nonce = req.get('nonce') if isinstance(req, dict) else None
|
||||||
|
|
||||||
|
# Maintainers
|
||||||
|
print(f"[ws] req error @{user_id} {action!r}: {err!r}")
|
||||||
|
print(traceback.format_exc())
|
||||||
|
# Client — nonce makes this routable to the caller's promise/callback
|
||||||
|
try:
|
||||||
|
await send({'error': str(err), 'nonce': nonce})
|
||||||
|
except Exception:
|
||||||
|
print(traceback.format_exc())
|
||||||
|
|
||||||
|
# Actual processing
|
||||||
|
async def process_request(app, user_id, session_connections, send, req):
|
||||||
|
if 'action' not in req:
|
||||||
await send({'error': 'no action'})
|
await send({'error': 'no action'})
|
||||||
continue
|
return
|
||||||
action = req['action']
|
action = req['action']
|
||||||
body = req.get('body', {})
|
body = req.get('body', {})
|
||||||
nonce = req.get('nonce', None)
|
nonce = req.get('nonce', None)
|
||||||
print(f"[ws] action @{user_id} {action}")
|
print(f"[ws] req @{user_id} {action}")
|
||||||
|
|
||||||
async def agent_id_from_body (allow_none=False):
|
async def agent_id_from_body (allow_none=False):
|
||||||
if 'agentId' in body:
|
if 'agentId' in body:
|
||||||
@@ -88,7 +128,7 @@ def register(server, app, auth_manager):
|
|||||||
raise Exception("field 'agentId' or 'agentname' required")
|
raise Exception("field 'agentId' or 'agentname' required")
|
||||||
|
|
||||||
if action == "ping":
|
if action == "ping":
|
||||||
await send({'message': "pong"})
|
await send({'message': "pong", 'nonce': nonce})
|
||||||
|
|
||||||
elif action == "create-agent":
|
elif action == "create-agent":
|
||||||
name = body['name']
|
name = body['name']
|
||||||
@@ -300,7 +340,7 @@ def register(server, app, auth_manager):
|
|||||||
elif action == "prompt-agent":
|
elif action == "prompt-agent":
|
||||||
if 'instruction' not in body and 'input' not in body:
|
if 'instruction' not in body and 'input' not in body:
|
||||||
await send({'error': "instruction or input required", 'nonce': nonce})
|
await send({'error': "instruction or input required", 'nonce': nonce})
|
||||||
continue
|
return
|
||||||
|
|
||||||
# Multiple-result action, same shape as read-stream: every
|
# Multiple-result action, same shape as read-stream: every
|
||||||
# generated message is its own packet carrying the request
|
# generated message is its own packet carrying the request
|
||||||
@@ -337,18 +377,3 @@ def register(server, app, auth_manager):
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
await send({'error': 'unknown action: ' + action, 'nonce': nonce})
|
await send({'error': 'unknown action: ' + action, 'nonce': nonce})
|
||||||
finally:
|
|
||||||
print("User logged out:", user_id)
|
|
||||||
sender_task.cancel()
|
|
||||||
if ws_task in app.ws_tasks:
|
|
||||||
app.ws_tasks.remove(ws_task)
|
|
||||||
# Unregister the event listener: the send closure would
|
|
||||||
# otherwise keep queueing events for a dead socket forever
|
|
||||||
if stop_listening:
|
|
||||||
stop_listening()
|
|
||||||
# All remotes served by this session are disconnected with it.
|
|
||||||
# disconnectRemote is idempotent, so remotes that were already
|
|
||||||
# disconnected manually (possibly from another session) are safe.
|
|
||||||
for remotename in list(session_connections):
|
|
||||||
app.disconnectRemote(user_id, remotename)
|
|
||||||
app.remove_user_connection(user_id, send_queue)
|
|
||||||
+13
-11
@@ -1,27 +1,29 @@
|
|||||||
# Drives
|
# 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 `<a>` 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 `<a>` links.
|
||||||
|
|
||||||
## Logical model
|
## 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.
|
- **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.
|
- **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.
|
- **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
|
## Backend interface
|
||||||
|
|
||||||
Download links can't set headers, so the GET/DELETE file routes additionally accept the token as a `?token=` query parameter.
|
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 |
|
| Route | Behavior |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `GET /files` | List the user's drives: `[{id, name}]` |
|
| `GET /files` | List the user's drives: `[{id, name}]`. Not implemented in `drives.py`. |
|
||||||
| `GET /files/<drive>` | List files: `[{id, name, size, mime, created}]` |
|
| `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}`. |
|
| `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 their stored mime type, or return `{id, name, size, mime, created, accessed}` when `?metadata` is present. |
|
| `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 `{fileId}`. |
|
| `DELETE /files/<drive>/<filename>` | 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
|
## 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.
|
- `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)`
|
- `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/<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.
|
- `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
|
## 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.
|
- 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.
|
- 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
|
## Known issues
|
||||||
|
|
||||||
- `uploadDriveFile` POSTs to `/files/<drive>`, 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.
|
- 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.
|
- 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.
|
- `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.
|
||||||
@@ -379,6 +379,8 @@ export function invalidateCache({ chats=false, agents=false } = {}) {
|
|||||||
// Drive endpoints are plain HTTP routes instead of WebSocket actions,
|
// Drive endpoints are plain HTTP routes instead of WebSocket actions,
|
||||||
// so they authenticate with the bearer token in the Authorization header.
|
// 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 () {
|
export function getDrives () {
|
||||||
return webFetch('/files');
|
return webFetch('/files');
|
||||||
}
|
}
|
||||||
@@ -387,10 +389,10 @@ export function getDriveFiles (driveName) {
|
|||||||
return webFetch(`/files/${driveName}`);
|
return webFetch(`/files/${driveName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uploadDriveFile (driveName, file) {
|
export function uploadDriveFile (driveName, file, path) {
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
body.append('file', file, file.name);
|
body.append('file', file, file.name);
|
||||||
return webFetch(`/files/${driveName}`, {
|
return webFetch(`/files/${driveName}/${path}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user