fix(files): ending every session ends every API token, and the share links hold their folder
- A password reset or change, "log out everywhere" and a revoked untrusted password revoke all of the account's API tokens too (audited): one minted from a stolen session no longer outlives the answer. - A bearer token or link grant with a non-ASCII character is refused (401/403), not a 500. - Making a token's grants or a folder link holds the folder, so a racing delete cannot fail the foreign key; a link's view count is one atomic UPDATE. - A folder link's card counts exactly the files its page lists (one `links.folder_files`). - A file with no extension is served without a trailing dot. - 0106's downgrade names a Hub folder so it never collides with one of the user's folders. - The speed baseline reads share tokens through the app (the database keeps them hashed/encrypted). - Tests: the token scope on the real session stack, the folder poster, the Hub refused for a link, 0106's downgrade data step, 0107 without a key; docs/comments follow (three folder kinds, folder links, key change, the cleanup job's token warnings).
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""The folders of the Files library — where each of a user's files sits.
|
||||
|
||||
A folder is a row, never a directory: on disk a file stays where the download layout put it (one
|
||||
shared asset can be held by several users), so a rename or a move is a column update. Two kinds:
|
||||
shared asset can be held by several users), so a rename or a move is a column update. Three kinds:
|
||||
|
||||
- a SOURCE folder is made automatically, one per origin a user has files from (YouTube, Plex clips,
|
||||
uploads, one per other site). Its `source_key` names the origin; the client renders its name
|
||||
@@ -13,11 +13,11 @@ shared asset can be held by several users), so a rename or a move is a column up
|
||||
|
||||
Every file of a user is in exactly one folder. The folder a new file lands in is decided here
|
||||
(`source_key_for` + `source_folder`), at every place a job is created, so the rule lives once. A file
|
||||
moves only into one of the user's own folders or back to its own source folder (`move`): a YouTube
|
||||
file in the TikTok folder would be a lie the folder's name tells.
|
||||
moves only into one of the user's own folders, their Hub folder, or back to its own source folder
|
||||
(`move`): a YouTube file in the TikTok folder would be a lie the folder's name tells.
|
||||
|
||||
Retention is the folder's: a source folder keeps a file `download_retention_days` (the value when the
|
||||
folder was made; the user can change it), Uploads and own folders keep it until it is deleted. A
|
||||
folder was made; the user can change it), Uploads, own folders and the Hub keep it until it is deleted. A
|
||||
done file carries its own `expires_at`, stamped by `done_expiry` when it finishes — unless the user
|
||||
kept it — and its `ready_at`, the moment it finished (`stamp_done`).
|
||||
"""
|
||||
@@ -202,8 +202,9 @@ def own_folder(db: Session, user_id: int, folder_id: int, *, hold: bool = True)
|
||||
|
||||
|
||||
def upload_target(db: Session, user_id: int, folder_id: int, *, hold: bool = True) -> FileFolder:
|
||||
"""The folder an upload asked to land in, if it may: one of the user's own folders, or their
|
||||
Uploads folder. A file put in a site's folder would claim an origin it does not have."""
|
||||
"""The folder an upload asked to land in, if it may: one of the user's own folders, their Hub
|
||||
folder, or their Uploads folder. A file put in a site's folder would claim an origin it does not
|
||||
have."""
|
||||
folder = own_folder(db, user_id, folder_id, hold=hold)
|
||||
if folder.kind == FolderKind.SOURCE and folder.source_key != SourceKey.UPLOAD:
|
||||
raise FolderError("file_folder_not_its_source")
|
||||
@@ -225,7 +226,8 @@ def set_kept(db: Session, job: DownloadJob, kept: bool) -> None:
|
||||
|
||||
|
||||
def move(db: Session, job: DownloadJob, folder: FileFolder) -> None:
|
||||
"""Put a file in `folder` — one of its user's own folders, or the source folder it came from.
|
||||
"""Put a file in `folder` — one of its user's own folders, their Hub folder, or the source folder
|
||||
it came from.
|
||||
The caller has checked that `folder` is the job's user's."""
|
||||
if folder.kind == FolderKind.SOURCE and folder.source_key != source_key_for(db, job):
|
||||
raise FolderError("file_folder_not_its_source")
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Public "share by link" helpers for the download center.
|
||||
|
||||
A DownloadLink is a capability URL: the unguessable token grants anyone read access to one
|
||||
download's file on the login-free `/watch/{token}` page. So the token is never stored as written:
|
||||
download's file, or to the files in one of its owner's own folders (`folder_files`), on the
|
||||
login-free `/watch/{token}` page. So the token is never stored as written:
|
||||
the link is found by its SHA-256, and the owner's copy is Fernet-encrypted — a database copy alone
|
||||
opens nothing. Without that copy (no TOKEN_ENCRYPTION_KEY, or a key since changed) the link still
|
||||
opens; only its address cannot be shown again, and the owner makes a new link to share.
|
||||
@@ -21,9 +22,14 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import security
|
||||
from app.config import settings
|
||||
from app.models import DownloadLink
|
||||
from app.downloads import storage
|
||||
from app.downloads.status import JobStatus
|
||||
from app.models import DownloadJob, DownloadLink, FileFolder, MediaAsset
|
||||
|
||||
_GRANT_TTL = 6 * 3600 # a watch session's grant is good for 6h, then the viewer re-unlocks
|
||||
# The most files a folder link lists: as many as the owner's own Files list shows (`routes.files.
|
||||
# MAX_LIST`; a test holds the two equal — this layer may not import that one).
|
||||
FOLDER_MAX = 500
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
@@ -48,6 +54,34 @@ def recover(link: DownloadLink) -> str | None:
|
||||
return security.decrypt_optional(link.token_enc)
|
||||
|
||||
|
||||
def in_folder(folder: FileFolder) -> tuple:
|
||||
"""Which jobs a link to `folder` reaches: the done files in it NOW, the folder owner's. A file
|
||||
moved out stops opening through the link at its next request; one moved in starts."""
|
||||
return (
|
||||
DownloadJob.folder_id == folder.id,
|
||||
DownloadJob.user_id == folder.user_id,
|
||||
DownloadJob.status == JobStatus.DONE,
|
||||
)
|
||||
|
||||
|
||||
def folder_files(db: Session, folder: FileFolder) -> list[tuple[DownloadJob, MediaAsset]]:
|
||||
"""What a link to `folder` shows — its page lists exactly these, and its link card counts them:
|
||||
each file `in_folder` whose asset is ready and whose bytes are on disk (so the list never offers
|
||||
a dead row), newest first, at most `FOLDER_MAX`."""
|
||||
rows = db.execute(
|
||||
select(DownloadJob, MediaAsset)
|
||||
.join(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
||||
.where(*in_folder(folder), MediaAsset.status == "ready")
|
||||
.order_by(DownloadJob.ready_at.desc().nulls_last(), DownloadJob.id.desc())
|
||||
.limit(FOLDER_MAX)
|
||||
).all()
|
||||
return [
|
||||
(job, asset)
|
||||
for job, asset in rows
|
||||
if storage.safe_abs_path(settings.files_root, asset.rel_path) is not None
|
||||
]
|
||||
|
||||
|
||||
def is_expired(link: DownloadLink) -> bool:
|
||||
return link.expires_at is not None and link.expires_at <= datetime.now(timezone.utc)
|
||||
|
||||
@@ -81,7 +115,9 @@ def check_grant(token: str, grant: str | None, password_version: int) -> bool:
|
||||
return False
|
||||
if exp < datetime.now(timezone.utc).timestamp():
|
||||
return False
|
||||
return hmac.compare_digest(sig, _sign(f"{token}.{exp}.{password_version}"))
|
||||
# As bytes: compare_digest refuses (TypeError, a 500) a str with anything non-ASCII in it, and
|
||||
# `sig` is whatever the URL carried.
|
||||
return hmac.compare_digest(sig.encode(), _sign(f"{token}.{exp}.{password_version}").encode())
|
||||
|
||||
|
||||
# --- serialization -------------------------------------------------------------------------
|
||||
|
||||
@@ -11,13 +11,11 @@ import html
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import storage
|
||||
from app.downloads.status import JobStatus
|
||||
from app.models import DownloadJob, DownloadLink, FileFolder, MediaAsset
|
||||
|
||||
_OG_BLOCK = re.compile(r"<!--OG:START-->.*?<!--OG:END-->", re.DOTALL)
|
||||
@@ -157,13 +155,7 @@ def _folder_tags(db: Session, link: DownloadLink, url: str) -> str | None:
|
||||
folder = db.get(FileFolder, link.folder_id)
|
||||
if folder is None or not folder.name:
|
||||
return None
|
||||
count = db.execute(
|
||||
select(func.count(DownloadJob.id)).where(
|
||||
DownloadJob.folder_id == folder.id,
|
||||
DownloadJob.user_id == folder.user_id,
|
||||
DownloadJob.status == JobStatus.DONE,
|
||||
)
|
||||
).scalar_one()
|
||||
count = len(linksmod.folder_files(db, folder)) # the page's own list, so the two agree
|
||||
title = folder.name.strip()
|
||||
about = f"{count} file{'' if count == 1 else 's'} · Shared via Siftlode"
|
||||
return "\n ".join(
|
||||
|
||||
@@ -294,9 +294,13 @@ def served_media_type(media_kind: str | None, container: str | None) -> str | No
|
||||
def download_filename(display_name: str, container: str | None, path: Path) -> str:
|
||||
"""The Content-Disposition filename for a served download: the cleaned display name carrying a single
|
||||
correct extension — the asset container, else the file's own suffix — without doubling it when the
|
||||
name already ends in that extension. Shared by the private download and the public watch endpoints."""
|
||||
name already ends in that extension. A file with no extension at all is served without one —
|
||||
never as `name.` (Windows drops a trailing dot, so the saved file would not be the one named).
|
||||
Shared by the private download and the public watch endpoints."""
|
||||
ext = container or path.suffix.lstrip(".")
|
||||
base = display_filename(display_name)
|
||||
if not ext:
|
||||
return base
|
||||
if base.lower().endswith(f".{ext.lower()}"):
|
||||
base = base[: -(len(ext) + 1)]
|
||||
return f"{base}.{ext}"
|
||||
|
||||
Reference in New Issue
Block a user