Files
hub/docs/drives-plan.md
T

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_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:

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