feat: implement drives, backend and frontend
This commit is contained in:
1 parent
6ad0a461e4
commit
3283b77ab9
12 files changed
+781
-93
No files matched your search
@@ -0,0 +1,125 @@
|
||||
# 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.
|
||||
Reference in new issue
Block a user