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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user