4.9 KiB
4.9 KiB
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_requiredand read the user id fromcurrent_user.auth_id. GET /files/<id>additionally accepts?token=, because<a download>links cannot set anAuthorizationheader.
Implementation
3.1 Imports
Top of backend/src/server/web.py:
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:
@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
// 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>. /drivesis now an API route; the SPA must not mount a view at that exact path.drive_nameis a single URL segment (no/). The service layer should reject names containing path separators before they reachos.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.