Files
hub/docs/drives-plan.md
T

125 lines
4.9 KiB
Markdown

# Phase 3: Server Integration (REST)
## Objective
Attach drive operations to `backend/src/server/web.py` using standard web routes only. This supersedes the WebSocket-based protocol from `drives-report.md`: no WS actions (`list-drives`, `list-files`, `upload-file`), no chunked transfer. Upstream transfer is a single `multipart/form-data` POST per file.
Assumes `drive_service` from the earlier phases provides `init_drives`, `upload_file`, `get_drive_file_metadata` and `resolve_drive_file_for_serving`.
## Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/drives` | GET | List drives of the authenticated user. |
| `/drives/<drive_name>/files` | GET | List file metadata for one drive. |
| `/drives/<drive_name>/files` | POST | Upload one file (multipart, field `file`). Creates the drive if missing. |
| `/files/<int:file_id>` | GET | Raw download. |
| `/files/<int:file_id>/meta` | GET | Metadata for existence checks. |
### Auth
Reuse the existing `QuartAuth(server, mode="bearer")` setup:
- JSON endpoints use `@login_required` and read the user id from `current_user.auth_id`.
- `GET /files/<id>` additionally accepts `?token=`, because `<a download>` links cannot set an `Authorization` header.
## Implementation
### 3.1 Imports
Top of `backend/src/server/web.py`:
```python
import database as db
import drive_service
from quart import Quart, request, send_from_directory, send_file
from quart_auth import QuartAuth, AuthUser, login_user, login_required, current_user
```
### 3.2 Route handlers
Registered inside `start_server`, alongside the existing routes:
```python
@server.route('/drives', methods=['GET'])
@login_required
async def list_drives():
drives = await db.Drive.filter(user_id=current_user.auth_id)
return [{"id": d.id, "name": d.name} for d in drives]
@server.route('/drives/<drive_name>/files', methods=['GET'])
@login_required
async def list_files(drive_name):
files = await db.DriveFile.filter(drive__user_id=current_user.auth_id, drive__name=drive_name)
return [{
"id": f.id,
"name": f.filename,
"size": f.size_bytes,
"mime": f.mime_type,
"created": f.created_at.isoformat(),
} for f in files]
@server.route('/drives/<drive_name>/files', methods=['POST'])
@login_required
async def upload_file(drive_name):
# whole file in one request, no partial transmission
upload = (await request.files).get('file')
if upload is None:
return "missing file part", 400
file_id = await drive_service.upload_file(
current_user.auth_id, drive_name, upload.filename, upload.stream, upload.content_type,
)
return {"fileId": file_id}
@server.route('/files/<int:file_id>', methods=['GET'])
async def serve_file(file_id):
# query token instead of @login_required: anchor downloads can't set headers
user_id = auth_manager.load_token(request.args.get('token', ''))
if user_id is None:
return "unauthorized", 401
try:
file_path, mime_type = await drive_service.resolve_drive_file_for_serving(file_id, user_id)
return await send_file(file_path, mimetype=mime_type)
except FileNotFoundError:
return "not found", 404
except PermissionError:
return "forbidden", 403
@server.route('/files/<int:file_id>/meta', methods=['GET'])
@login_required
async def get_file_meta(file_id):
try:
# resolve performs the ownership check before anything is returned
await drive_service.resolve_drive_file_for_serving(file_id, current_user.auth_id)
return await drive_service.get_drive_file_metadata(file_id)
except FileNotFoundError:
return "not found", 404
except PermissionError:
return "forbidden", 403
```
No changes to `server/websocket.py`.
## Frontend protocol
```javascript
// listing
const drives = await fetch('/drives', {headers: {Authorization: `Bearer ${token}`}}).then(r => r.json());
const files = await fetch(`/drives/${encodeURIComponent(name)}/files`, {headers: {Authorization: `Bearer ${token}`}}).then(r => r.json());
// upload: single POST, no chunking
const form = new FormData();
form.append('file', blob, 'report.pdf');
await fetch(`/drives/${encodeURIComponent(driveName)}/files`, {
method: 'POST',
headers: {Authorization: `Bearer ${token}`},
body: form,
});
// download: plain anchor
// <a href={`/files/${fileId}?token=${token}`} download={file.name}>Download</a>
```
## Notes
- Routing is unaffected by the static catch-all: werkzeug prefers the exact/typed routes above over `/<path:path>`.
- `/drives` is now an API route; the SPA must not mount a view at that exact path.
- `drive_name` is a single URL segment (no `/`). The service layer should reject names containing path separators before they reach `os.path.join`.
- The query-token on the download endpoint trades header auth for `<a download>` compatibility (token visible in history/logs). Switch to fetch + blob or cookie auth if that's unacceptable.