125 lines
4.9 KiB
Markdown
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. |