Skip to content

Commit 2e2d925

Browse files
Upload files from the user's computer into a project's Uploads folder, in resumable chunks, never over an existing file, with the new files.upload permission and a size limit setting
1 parent 9c87c3c commit 2e2d925

12 files changed

Lines changed: 551 additions & 12 deletions

File tree

‎AGENTS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,8 @@ The web's file browser (`RemoteFileDialog`) reads `GET /projects/{p}/protocols/{
214214

215215
Writes go inside the project, or, in the import area, only as `.json` files outside hidden folders (exported workflows); never with `scope=filesystem`. The start-path answer includes `projectRootAbs` so the browser can offer the project folder. Listings come from `os.scandir` with `mtime` and `isLink`; text previews show at most 1 MB (the end of a log, the start of anything else, marked `X-Preview-Truncated`); `.star`, `.xmd`, `.csv` and `.tsv` preview as tables (`utils/star_table.py`: every `data_` block, its first 200 rows and how many there are, reading the file line by line).
216216

217+
**Uploads from the user's computer** (`routers/project_upload_router.py`, `services/project_upload_service.py`): a file goes into the project's `Uploads/` folder, in chunks, so a big file passes a proxy's request limit and a dropped connection resumes. `POST /projects/{p}/uploads` {name, size} opens it (a plain visible name, never a path; the size limit `SCIPIONWEB_UPLOAD_MAX_GB`, default 10, 0 = none, in Settings → Environment; free disk space for it plus 2 GB, or 507); `PUT .../uploads/{id}?offset=N` appends the raw body (at most 64 MB; an offset other than what arrived answers 409 with `received`, which `GET .../uploads/{id}` also gives); `POST .../uploads/{id}/complete` names it with a hard link, so an existing file is never replaced (409), and records `projects.fileUploaded` in the audit log; `DELETE` drops it. Partial files and their descriptions live in `Uploads/.partial`, belong to the user who started them, and are removed after a day of no change when another upload starts. The router's guard needs work access and `files.upload` (granted with `files.download` by migration `f2b8d4c6a917`). The web offers it in the protocol form's file browser (Upload button and dropping a file on the list) and selects the file once it is there.
218+
217219
Downloads: a plain link cannot carry the bearer token, so `POST .../fs/download-link` (authenticated, checked against the same boundary) returns a signed link (`fs_download_token`, HMAC with `SECRET_KEY`, bound to the absolute file, user and project, valid 2 hours) to `GET .../fs/download-file?token=`, which needs no bearer token. The browser's own download manager then streams the file to disk, shows progress and resumes it with range requests (Starlette `FileResponse`) when the connection drops, e.g. over a VPN. `fs/download` also streams now instead of reading whole files into memory. Signed links are only as safe as `SECRET_KEY`: it must be set in production (it also signs the JWTs).
218220

219221
## Workspace, overview, streaming monitor, disk usage and launch history
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Give the new files.upload permission to the roles that may download files.
2+
3+
Uploading from one's computer into a project's Uploads folder is new; the roles
4+
of an existing installation that hold files.download (User, Technician and
5+
Facility manager, as created, or an administrator's own) get it too, as new
6+
installations do. The Administrator role holds every permission anyway.
7+
8+
Revision ID: f2b8d4c6a917
9+
Revises: e6c1b9a4d203
10+
"""
11+
from alembic import op
12+
13+
14+
revision = "f2b8d4c6a917"
15+
down_revision = "e6c1b9a4d203"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
op.execute("""
22+
INSERT INTO access_role_permissions ("roleId", permission)
23+
SELECT "roleId", 'files.upload'
24+
FROM access_role_permissions
25+
WHERE permission = 'files.download'
26+
ON CONFLICT DO NOTHING
27+
""")
28+
29+
30+
def downgrade():
31+
op.execute("DELETE FROM access_role_permissions WHERE permission = 'files.upload'")

‎app/backend/api/permissions.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
# Files
2626
"files.browseServer": "Browse the whole file system of the server",
2727
"files.download": "Download files of the projects one can see",
28+
"files.upload": "Upload files from one's computer into the projects one works on",
2829
# Facility
2930
"facility.monitor": "See the facility monitor with every active stream",
3031
# A project's own monitor thresholds need work access to the project, not this
@@ -44,13 +45,13 @@
4445
(SYSTEM_ROLE_NAME, "Everything, now and in the future", True, False, tuple(PERMISSIONS)),
4546
("Facility manager", "Runs the facility: users, groups, microscopes and templates", False, False,
4647
("users.manage", "groups.manage", "projects.create", "projects.share", "projects.viewAll",
47-
"files.browseServer", "files.download", "facility.monitor", "facility.thresholds", "facility.sessions",
48-
"facility.manage")),
48+
"files.browseServer", "files.download", "files.upload", "facility.monitor", "facility.thresholds",
49+
"facility.sessions", "facility.manage")),
4950
("Technician", "Facility staff: sessions, the monitor and the projects of the facility", False, False,
5051
("projects.create", "projects.share", "projects.viewAll", "files.browseServer", "files.download",
51-
"facility.monitor", "facility.thresholds", "facility.sessions")),
52+
"files.upload", "facility.monitor", "facility.thresholds", "facility.sessions")),
5253
("User", "Works on their own projects and the ones shared with them", False, True,
53-
("projects.create", "projects.share", "files.download")),
54+
("projects.create", "projects.share", "files.download", "files.upload")),
5455
("Guest", "Only sees what is shared with them", False, False, ()),
5556
)
5657

‎app/backend/api/project_access.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@
4646
("POST", "/projects/import"): "projects.create",
4747
("POST", "/projects/{projectId}/share"): "projects.share",
4848
("POST", "/projects/{projectId}/protocols/{protocolId}/fs/download-link"): "files.download",
49+
("POST", "/projects/{projectId}/uploads"): "files.upload",
50+
("PUT", "/projects/{projectId}/uploads/{uploadId}"): "files.upload",
51+
("POST", "/projects/{projectId}/uploads/{uploadId}/complete"): "files.upload",
4952
}
5053

5154

‎app/backend/api/routers/project_router.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2055,7 +2055,6 @@ def createFsDownloadLink(
20552055
"""A short-lived signed link to download one file with the browser's own download manager."""
20562056
from app.backend.api.services import fs_download_token
20572057

2058-
checkPermission(currentUser, "files.download")
20592058
browser = _fsBrowserRoot(projectId, protocolId, payload.scope, currentUser, mapper, service)
20602059
target = _fsTarget(browser, payload.path)
20612060
if not target.is_file():
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Uploading files from the user's computer into a project's Uploads folder (see project_upload_service).
2+
3+
Work access to the project and the files.upload permission are checked by the router's guard
4+
(project_access) before each route runs.
5+
"""
6+
import logging
7+
from pathlib import Path
8+
9+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
10+
from fastapi.concurrency import run_in_threadpool
11+
from pydantic import BaseModel, Field
12+
13+
from app.backend.api.audit import recordAudit
14+
from app.backend.api.dependencies import getCurrentUser
15+
from app.backend.api.project_access import projectRouteAccess
16+
from app.backend.api.services import project_upload_service as uploads
17+
from app.backend.api.services.project_service import ProjectService
18+
from app.backend.database import getMapper
19+
from app.backend.mapper.postgresql import PostgresqlFlatMapper
20+
21+
logger = logging.getLogger(__name__)
22+
23+
router = APIRouter(prefix="/projects", tags=["uploads"], dependencies=[Depends(projectRouteAccess(getMapper))])
24+
25+
26+
def getProjectService() -> ProjectService:
27+
return ProjectService()
28+
29+
30+
class UploadStartRequest(BaseModel):
31+
name: str = Field(..., min_length=1, max_length=uploads.MAX_NAME_LENGTH)
32+
size: int = Field(..., ge=0)
33+
34+
35+
def _projectRoot(projectId: int, currentUser: dict, mapper: PostgresqlFlatMapper, service: ProjectService) -> Path:
36+
project = service.getProjectDbRow(mapper, projectId, currentUser)
37+
if not project:
38+
raise HTTPException(status_code=404, detail="Project not found")
39+
return Path(project["name"]).expanduser().resolve()
40+
41+
42+
def _refused(error: uploads.UploadError) -> HTTPException:
43+
detail = {"message": error.message}
44+
if error.received is not None:
45+
detail["received"] = error.received
46+
return HTTPException(status_code=error.status, detail=detail if error.received is not None else error.message)
47+
48+
49+
@router.post("/{projectId}/uploads")
50+
def startUpload(projectId: int, payload: UploadStartRequest, currentUser=Depends(getCurrentUser),
51+
mapper: PostgresqlFlatMapper = Depends(getMapper),
52+
service: ProjectService = Depends(getProjectService)):
53+
"""Open an upload of one file into Uploads/; its chunks follow."""
54+
root = _projectRoot(projectId, currentUser, mapper, service)
55+
try:
56+
upload = uploads.startUpload(root, int(currentUser["id"]), payload.name, payload.size)
57+
except uploads.UploadError as error:
58+
raise _refused(error)
59+
return {**upload.toDict(), "chunkLimit": uploads.MAX_CHUNK_BYTES}
60+
61+
62+
@router.get("/{projectId}/uploads/{uploadId}")
63+
def uploadStatus(projectId: int, uploadId: str, currentUser=Depends(getCurrentUser),
64+
mapper: PostgresqlFlatMapper = Depends(getMapper),
65+
service: ProjectService = Depends(getProjectService)):
66+
"""How much of an upload has arrived (to resume it after a dropped connection)."""
67+
root = _projectRoot(projectId, currentUser, mapper, service)
68+
try:
69+
return uploads.uploadStatus(root, int(currentUser["id"]), uploadId).toDict()
70+
except uploads.UploadError as error:
71+
raise _refused(error)
72+
73+
74+
@router.put("/{projectId}/uploads/{uploadId}")
75+
async def writeUploadChunk(projectId: int, uploadId: str, request: Request,
76+
offset: int = Query(..., ge=0, description="Where this chunk starts in the file"),
77+
currentUser=Depends(getCurrentUser),
78+
mapper: PostgresqlFlatMapper = Depends(getMapper),
79+
service: ProjectService = Depends(getProjectService)):
80+
"""Append one chunk (the raw request body) at offset."""
81+
declared = request.headers.get("content-length")
82+
if declared and declared.isdigit() and int(declared) > uploads.MAX_CHUNK_BYTES:
83+
raise HTTPException(status_code=413, detail="Chunks may be up to %d bytes" % uploads.MAX_CHUNK_BYTES)
84+
root = await run_in_threadpool(_projectRoot, projectId, currentUser, mapper, service)
85+
chunk = await request.body()
86+
try:
87+
upload = await run_in_threadpool(uploads.writeChunk, root, int(currentUser["id"]), uploadId, offset, chunk)
88+
except uploads.UploadError as error:
89+
raise _refused(error)
90+
return upload.toDict()
91+
92+
93+
@router.post("/{projectId}/uploads/{uploadId}/complete")
94+
def completeUpload(projectId: int, uploadId: str, currentUser=Depends(getCurrentUser),
95+
mapper: PostgresqlFlatMapper = Depends(getMapper),
96+
service: ProjectService = Depends(getProjectService)):
97+
"""Give the uploaded file its name in Uploads/ (never over an existing file)."""
98+
root = _projectRoot(projectId, currentUser, mapper, service)
99+
try:
100+
upload = uploads.completeUpload(root, int(currentUser["id"]), uploadId)
101+
except uploads.UploadError as error:
102+
raise _refused(error)
103+
recordAudit(mapper, currentUser, "projects.fileUploaded", "project", projectId, root.name,
104+
{"file": "%s/%s" % (uploads.UPLOADS_FOLDER, upload.name), "bytes": upload.size})
105+
return {**upload.toDict(), "absPath": (uploads.uploadsFolder(root) / upload.name).as_posix()}
106+
107+
108+
@router.delete("/{projectId}/uploads/{uploadId}")
109+
def cancelUpload(projectId: int, uploadId: str, currentUser=Depends(getCurrentUser),
110+
mapper: PostgresqlFlatMapper = Depends(getMapper),
111+
service: ProjectService = Depends(getProjectService)):
112+
"""Drop an unfinished upload."""
113+
root = _projectRoot(projectId, currentUser, mapper, service)
114+
try:
115+
uploads.cancelUpload(root, int(currentUser["id"]), uploadId)
116+
except uploads.UploadError as error:
117+
raise _refused(error)
118+
return {"cancelled": True}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""Files uploaded from the user's computer into a project's Uploads folder.
2+
3+
A volume, a mask or a reference made on one's own machine has to reach the
4+
server before a protocol can use it. An upload goes in chunks, so a big file
5+
neither fills the server's memory nor passes a proxy's request limit, and a
6+
dropped connection (a VPN) resumes where it stopped:
7+
8+
1. ``startUpload`` checks the name, the size limit (``SCIPIONWEB_UPLOAD_MAX_GB``)
9+
and the free disk space, and opens a partial file;
10+
2. ``writeChunk`` appends each chunk at the offset the client says, which must
11+
be what was received so far (``uploadStatus`` tells it after a drop);
12+
3. ``completeUpload`` gives the file its name, never over an existing file.
13+
14+
Partial files and what they are (``<id>.json``) live in ``Uploads/.partial``;
15+
nothing is kept in memory, so an upload survives a restart of the server.
16+
Partial files untouched for a day are removed when a new upload starts.
17+
"""
18+
from __future__ import annotations
19+
20+
import json
21+
import os
22+
import shutil
23+
import time
24+
import uuid
25+
from dataclasses import dataclass
26+
from pathlib import Path
27+
from typing import Optional
28+
29+
UPLOADS_FOLDER = "Uploads"
30+
PARTIAL_FOLDER = ".partial"
31+
DEFAULT_MAX_GB = 10.0
32+
# The largest chunk one request may carry; the web sends 8 MB
33+
MAX_CHUNK_BYTES = 64 * 1024 * 1024
34+
# Free space left on the disk after an upload, so a full disk does not stop the database
35+
FREE_SPACE_MARGIN_BYTES = 2 * 1024 ** 3
36+
STALE_PARTIAL_SECONDS = 24 * 3600
37+
MAX_NAME_LENGTH = 255
38+
39+
40+
class UploadError(Exception):
41+
"""A refused upload: status is the HTTP status the route answers."""
42+
43+
def __init__(self, status: int, message: str, received: Optional[int] = None):
44+
super().__init__(message)
45+
self.status = status
46+
self.message = message
47+
self.received = received
48+
49+
50+
@dataclass(frozen=True)
51+
class Upload:
52+
uploadId: str
53+
name: str
54+
size: int
55+
received: int
56+
userId: int
57+
58+
def toDict(self) -> dict:
59+
return {"uploadId": self.uploadId, "name": self.name, "size": self.size, "received": self.received,
60+
"path": "%s/%s" % (UPLOADS_FOLDER, self.name)}
61+
62+
63+
def maxUploadBytes() -> int:
64+
"""The size limit of one file (SCIPIONWEB_UPLOAD_MAX_GB; 0 or less: no limit)."""
65+
try:
66+
gigabytes = float(os.environ.get("SCIPIONWEB_UPLOAD_MAX_GB") or DEFAULT_MAX_GB)
67+
except ValueError:
68+
gigabytes = DEFAULT_MAX_GB
69+
return int(gigabytes * 1024 ** 3) if gigabytes > 0 else 0
70+
71+
72+
def safeName(name: str) -> str:
73+
"""The file name an upload is kept under: a plain, visible name, never a path."""
74+
text = str(name or "").strip()
75+
if (not text or text in (".", "..") or text.startswith(".") or len(text) > MAX_NAME_LENGTH
76+
or any(char in text for char in ("/", "\\", "\0")) or any(ord(char) < 32 for char in text)):
77+
raise UploadError(400, "Invalid file name: %r" % str(name or ""))
78+
return text
79+
80+
81+
def uploadsFolder(projectRoot: Path) -> Path:
82+
return Path(projectRoot) / UPLOADS_FOLDER
83+
84+
85+
def _partialFolder(projectRoot: Path) -> Path:
86+
return uploadsFolder(projectRoot) / PARTIAL_FOLDER
87+
88+
89+
def _validId(uploadId: str) -> str:
90+
text = str(uploadId or "")
91+
if len(text) != 32 or any(char not in "0123456789abcdef" for char in text):
92+
raise UploadError(404, "Upload not found")
93+
return text
94+
95+
96+
def _paths(projectRoot: Path, uploadId: str):
97+
folder = _partialFolder(projectRoot)
98+
uploadId = _validId(uploadId)
99+
return folder / (uploadId + ".part"), folder / (uploadId + ".json")
100+
101+
102+
def _load(projectRoot: Path, uploadId: str, userId: int) -> Upload:
103+
data, meta = _paths(projectRoot, uploadId)
104+
try:
105+
info = json.loads(meta.read_text(encoding="utf-8"))
106+
received = data.stat().st_size
107+
except (OSError, ValueError):
108+
raise UploadError(404, "Upload not found")
109+
# Only who started an upload may go on with it
110+
if int(info.get("userId", -1)) != int(userId):
111+
raise UploadError(404, "Upload not found")
112+
return Upload(uploadId, str(info["name"]), int(info["size"]), received, int(userId))
113+
114+
115+
def _removeStalePartials(folder: Path, now: float) -> None:
116+
try:
117+
entries = list(folder.iterdir())
118+
except OSError:
119+
return
120+
for entry in entries:
121+
try:
122+
if entry.is_file() and now - entry.stat().st_mtime > STALE_PARTIAL_SECONDS:
123+
entry.unlink()
124+
except OSError:
125+
continue
126+
127+
128+
def startUpload(projectRoot: Path, userId: int, name: str, size: int) -> Upload:
129+
name = safeName(name)
130+
if not isinstance(size, int) or size < 0:
131+
raise UploadError(400, "Invalid file size")
132+
limit = maxUploadBytes()
133+
if limit and size > limit:
134+
raise UploadError(413, "%s is %.1f GB; files up to %.1f GB can be uploaded"
135+
% (name, size / 1024 ** 3, limit / 1024 ** 3))
136+
target = uploadsFolder(projectRoot) / name
137+
if target.exists() or target.is_symlink():
138+
raise UploadError(409, "Uploads/%s already exists" % name)
139+
folder = _partialFolder(projectRoot)
140+
folder.mkdir(parents=True, exist_ok=True)
141+
free = shutil.disk_usage(folder).free
142+
if size + FREE_SPACE_MARGIN_BYTES > free:
143+
raise UploadError(507, "Not enough free disk space on the server for %s (%.1f GB free)"
144+
% (name, free / 1024 ** 3))
145+
_removeStalePartials(folder, time.time())
146+
uploadId = uuid.uuid4().hex
147+
data, meta = _paths(projectRoot, uploadId)
148+
data.touch()
149+
meta.write_text(json.dumps({"name": name, "size": size, "userId": int(userId)}), encoding="utf-8")
150+
return Upload(uploadId, name, size, 0, int(userId))
151+
152+
153+
def uploadStatus(projectRoot: Path, userId: int, uploadId: str) -> Upload:
154+
return _load(projectRoot, uploadId, userId)
155+
156+
157+
def writeChunk(projectRoot: Path, userId: int, uploadId: str, offset: int, chunk: bytes) -> Upload:
158+
"""Append chunk at offset, which must be what was received so far (409 tells the right one)."""
159+
upload = _load(projectRoot, uploadId, userId)
160+
if offset != upload.received:
161+
raise UploadError(409, "Expected the chunk at byte %d" % upload.received, received=upload.received)
162+
if len(chunk) > MAX_CHUNK_BYTES:
163+
raise UploadError(413, "Chunks may be up to %d bytes" % MAX_CHUNK_BYTES)
164+
if upload.received + len(chunk) > upload.size:
165+
raise UploadError(400, "More data than the declared size of %d bytes" % upload.size)
166+
data, _ = _paths(projectRoot, uploadId)
167+
with open(data, "ab") as handle:
168+
handle.write(chunk)
169+
return Upload(upload.uploadId, upload.name, upload.size, upload.received + len(chunk), upload.userId)
170+
171+
172+
def completeUpload(projectRoot: Path, userId: int, uploadId: str) -> Upload:
173+
upload = _load(projectRoot, uploadId, userId)
174+
if upload.received != upload.size:
175+
raise UploadError(409, "Only %d of %d bytes were received" % (upload.received, upload.size),
176+
received=upload.received)
177+
data, meta = _paths(projectRoot, uploadId)
178+
target = uploadsFolder(projectRoot) / upload.name
179+
try:
180+
# A link fails if the name is taken meanwhile: an existing file is never replaced
181+
os.link(data, target)
182+
except FileExistsError:
183+
raise UploadError(409, "Uploads/%s already exists" % upload.name)
184+
data.unlink()
185+
meta.unlink(missing_ok=True)
186+
return upload
187+
188+
189+
def cancelUpload(projectRoot: Path, userId: int, uploadId: str) -> None:
190+
_load(projectRoot, uploadId, userId)
191+
for path in _paths(projectRoot, uploadId):
192+
path.unlink(missing_ok=True)

0 commit comments

Comments
 (0)