fix(files): a token made as every session ends goes with them, and a file being shared holds its job

This commit is contained in:
2026-10-15 05:47:00 +02:00
parent 2efa7d5a75
commit 620f84dba4
5 changed files with 199 additions and 7 deletions
+12 -4
View File
@@ -1147,10 +1147,18 @@ def bump_session_epoch(db: Session, user: User) -> None:
"""Invalidate every EXISTING signed cookie for this account by advancing its epoch (SA4). Cookies
minted before the bump carry a now-stale epoch and are rejected by `current_user`. The account's
API tokens go with them (`apitokens.revoke_all`): a token minted from a stolen session must not
outlive the answer to that theft. Not committed — the caller commits it with its change."""
user.session_epoch = (user.session_epoch or 0) + 1
if user.id is not None:
apitokens.revoke_all(db, user.id)
outlive the answer to that theft. Not committed — the caller commits it with its change.
The user's row is locked before the tokens go, the lock `create_token` takes too: a token made at
the same moment either commits first and is deleted here, or waits and sees the new epoch."""
if user.id is None:
user.session_epoch = (user.session_epoch or 0) + 1
return
epoch = db.execute(
select(User.session_epoch).where(User.id == user.id).with_for_update()
).scalar_one()
user.session_epoch = (epoch or 0) + 1
apitokens.revoke_all(db, user.id)
def revoke_untrusted_preset_password(db: Session, user: User) -> None:
+17 -1
View File
@@ -797,7 +797,7 @@ def create_link(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
job = _shareable_job(db, user, job_id)
if job.status != JobStatus.DONE or job.asset_id is None:
raise refused(409, "share_unfinished")
link, token = _new_link(db, user, body, job_id=job_id)
@@ -805,6 +805,22 @@ def create_link(
return linksmod.owner_view(link, token=token)
def _shareable_job(db: Session, user: User, job_id: int) -> DownloadJob:
"""One of the user's files, held (FOR SHARE) for the rest of the transaction: a delete of it at
the same moment (the user's, or the retention sweep's) waits and takes the new link with it,
rather than failing the link's foreign key — or, done first, leaves a 404."""
owned_or_404(db, DownloadJob, job_id, user, what="download")
job = db.execute(
select(DownloadJob)
.where(DownloadJob.id == job_id)
.with_for_update(read=True)
.execution_options(populate_existing=True)
).scalar_one_or_none()
if job is None:
raise not_found("download")
return job
def _shareable_folder(db: Session, user: User, folder_id: int) -> FileFolder:
"""One of the user's own folders. A source folder lets its files go on its own clock, and the Hub
is the user's channel to a machine of theirs: neither is a shelf to hand out. Held (FOR SHARE)
+8 -2
View File
@@ -120,8 +120,14 @@ def create_token(
name = body.name.strip()
if not name:
raise refused(400, "api_token_name_required")
# The user's row, locked: two requests at once cannot both slip under the ceiling.
db.execute(select(User.id).where(User.id == user.id).with_for_update())
# The user's row, locked: two requests at once cannot both slip under the ceiling, and a
# session ended since `current_user` read the epoch (unlocked) cannot mint a token that outlives
# it — `bump_session_epoch` takes this lock before it revokes them all.
epoch = db.execute(
select(User.session_epoch).where(User.id == user.id).with_for_update()
).scalar_one()
if (epoch or 0) != (user.session_epoch or 0):
raise refused(401, "not_authenticated")
count = db.execute(
select(func.count(ApiToken.id)).where(ApiToken.user_id == user.id)
).scalar_one()