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:
@@ -89,7 +89,22 @@ def downgrade() -> None:
|
||||
op.drop_table("api_token_grants")
|
||||
op.drop_table("api_tokens")
|
||||
op.drop_index("uq_file_folder_hub", table_name="file_folders")
|
||||
# A Hub folder may hold files (their foreign key RESTRICTs a delete): it becomes an own folder.
|
||||
op.execute("UPDATE file_folders SET kind = 'own', name = 'Hub ' || id WHERE kind = 'hub'")
|
||||
# A Hub folder may hold files (their foreign key RESTRICTs a delete): it becomes an own folder,
|
||||
# named "Hub <id>" — or "Hub <id> (2)", … when its user already has a folder of that name,
|
||||
# which `uq_file_folder_own_name` (per user, case-blind) would refuse.
|
||||
conn = op.get_bind()
|
||||
hubs = conn.execute(sa.text("SELECT id, user_id FROM file_folders WHERE kind = 'hub'")).all()
|
||||
for folder_id, user_id in hubs:
|
||||
name, n = f"Hub {folder_id}", 1
|
||||
while conn.execute(
|
||||
sa.text("SELECT 1 FROM file_folders WHERE user_id = :u AND lower(name) = lower(:n)"),
|
||||
{"u": user_id, "n": name},
|
||||
).first():
|
||||
n += 1
|
||||
name = f"Hub {folder_id} ({n})"
|
||||
conn.execute(
|
||||
sa.text("UPDATE file_folders SET kind = 'own', name = :n WHERE id = :i"),
|
||||
{"n": name, "i": folder_id},
|
||||
)
|
||||
op.drop_constraint("ck_file_folder_kind", "file_folders", type_="check")
|
||||
op.create_check_constraint("ck_file_folder_kind", "file_folders", _KIND_OLD)
|
||||
|
||||
@@ -23,10 +23,11 @@ from datetime import datetime, timedelta, timezone
|
||||
from enum import StrEnum
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from sqlalchemy import or_, select, update
|
||||
from sqlalchemy import delete, or_, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import debugsession, logs
|
||||
from app import audit, debugsession, logs
|
||||
from app.audit import AuditAction
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import ApiToken, ApiTokenGrant, User
|
||||
@@ -99,7 +100,8 @@ def well_formed(raw: str) -> bool:
|
||||
if len(raw) != TOKEN_LEN or not raw.startswith(PREFIX):
|
||||
return False
|
||||
body, check = raw[len(PREFIX) : -_CHECK_LEN], raw[-_CHECK_LEN:]
|
||||
if any(c not in _ALPHABET for c in body):
|
||||
# The check too: compare_digest refuses (TypeError, a 500) a str with anything non-ASCII in it.
|
||||
if any(c not in _ALPHABET for c in body + check):
|
||||
return False
|
||||
return secrets.compare_digest(_check(body), check)
|
||||
|
||||
@@ -215,6 +217,28 @@ def _token_caller(request: Request, db: Session, raw: str) -> FilesCaller:
|
||||
return caller
|
||||
|
||||
|
||||
def revoke_all(db: Session, user_id: int) -> int:
|
||||
"""Revoke every token of `user_id`, each with its audit row (NOT committed). A token is a
|
||||
credential like a session, so whatever ends every session of an account — a password reset or
|
||||
change, "log out everywhere", a revoked untrusted password — ends its tokens too: one minted
|
||||
from a stolen session must not outlive the owner's answer to that theft. Returns how many."""
|
||||
gone = (
|
||||
db.execute(delete(ApiToken).where(ApiToken.user_id == user_id).returning(ApiToken.id))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for token_id in gone:
|
||||
audit.record(
|
||||
db,
|
||||
user_id,
|
||||
AuditAction.API_TOKEN_REVOKE,
|
||||
target_type="api_token",
|
||||
target_id=token_id,
|
||||
summary="revoked with every session of the account",
|
||||
)
|
||||
return len(gone)
|
||||
|
||||
|
||||
def expiring(token: ApiToken, now: datetime) -> bool:
|
||||
"""Whether `token` runs out within the warning span (and has not yet)."""
|
||||
return token.expires_at is not None and now < token.expires_at <= now + EXPIRY_WARNING
|
||||
|
||||
@@ -73,7 +73,8 @@ class AuditAction:
|
||||
# What a token then did is the file rows above, their summary naming it.
|
||||
API_TOKEN_CREATE = "api_token_create"
|
||||
API_TOKEN_REVOKE = "api_token_revoke"
|
||||
# A public `/watch` link to a file (`routes.downloads`): made, its settings changed, revoked.
|
||||
# A public `/watch` link to a file or a folder (`routes.downloads`): made, its settings changed,
|
||||
# revoked.
|
||||
# Never its token or password — only whether it has one.
|
||||
DOWNLOAD_LINK_CREATE = "download_link_create"
|
||||
DOWNLOAD_LINK_UPDATE = "download_link_update"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
+17
-13
@@ -16,6 +16,7 @@ from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import apitokens
|
||||
from app import email as email_mod
|
||||
from app import logs
|
||||
from app import mailtext
|
||||
@@ -446,7 +447,7 @@ async def callback(
|
||||
# SEC-10: drop any password nobody proved the mailbox for, now that Google attests
|
||||
# ownership (bumps the epoch to revoke any session it minted). Runs before the
|
||||
# activation below, which is what it reads.
|
||||
revoke_untrusted_preset_password(user)
|
||||
revoke_untrusted_preset_password(db, user)
|
||||
# Google verified the email and is_allowed granted access, so finish activating a
|
||||
# previously pending password registration (otherwise current_user would bounce it).
|
||||
user.email_verified = True
|
||||
@@ -486,7 +487,7 @@ async def callback(
|
||||
# real owner, signed in through Google right now, sets a new password in Settings.
|
||||
if canonical_email(email) in env_admin_canonicals():
|
||||
if user.role != "admin" and user.password_hash is not None:
|
||||
_revoke_password(user)
|
||||
_revoke_password(db, user)
|
||||
user.role = "admin"
|
||||
elif not user.role:
|
||||
user.role = "user"
|
||||
@@ -1118,9 +1119,8 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
|
||||
# about. The person still confirms their address the normal way.
|
||||
if kind == "reset":
|
||||
user.email_verified = True
|
||||
bump_session_epoch(
|
||||
user
|
||||
) # SA4: a reset (often a compromise response) kills all existing sessions.
|
||||
# SA4: a reset (often a compromise response) kills all existing sessions — and API tokens.
|
||||
bump_session_epoch(db, user)
|
||||
# Burn any other outstanding reset tokens for this user.
|
||||
for row in db.execute(
|
||||
select(AuthToken).where(
|
||||
@@ -1143,13 +1143,17 @@ def _remember_epoch(request: Request, user: User) -> None:
|
||||
request.session["epochs"] = epochs
|
||||
|
||||
|
||||
def bump_session_epoch(user: User) -> None:
|
||||
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`."""
|
||||
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)
|
||||
|
||||
|
||||
def revoke_untrusted_preset_password(user: User) -> None:
|
||||
def revoke_untrusted_preset_password(db: Session, user: User) -> None:
|
||||
"""SEC-10 (account pre-emption): a password set on an account whose email was NEVER verified is
|
||||
untrusted — whoever registered the address (to pre-empt the real owner) could have chosen it. When
|
||||
the real owner attests ownership through Google, drop that password and bump the epoch to revoke any
|
||||
@@ -1183,15 +1187,15 @@ def revoke_untrusted_preset_password(user: User) -> None:
|
||||
_send_reset_if_eligible keyed off exactly that, so a revoked account silently got no reset link and
|
||||
was locked out for good (it could not sign in either). The stamp separates the two cases."""
|
||||
if not user.email_verified or (not user.is_active and user.password_hash is not None):
|
||||
_revoke_password(user)
|
||||
_revoke_password(db, user)
|
||||
|
||||
|
||||
def _revoke_password(user: User) -> None:
|
||||
def _revoke_password(db: Session, user: User) -> None:
|
||||
"""Drop the account's password, stamp why (so password-reset still accepts it) and end every
|
||||
session it signed in."""
|
||||
user.password_hash = None
|
||||
user.password_revoked_at = datetime.now(timezone.utc)
|
||||
bump_session_epoch(user)
|
||||
bump_session_epoch(db, user)
|
||||
|
||||
|
||||
@router.post("/logout-others")
|
||||
@@ -1203,7 +1207,7 @@ def logout_others(
|
||||
in somewhere" / a suspected session compromise."""
|
||||
# Commit the bump BEFORE re-stamping this cookie, so a failed commit can't strand the current
|
||||
# session at a newer epoch than the DB (see set_password).
|
||||
bump_session_epoch(user)
|
||||
bump_session_epoch(db, user)
|
||||
db.commit()
|
||||
_remember_epoch(request, user)
|
||||
log.info("Logout-others: uid=%s (epoch=%s)", user.id, user.session_epoch)
|
||||
@@ -1255,7 +1259,7 @@ def set_password(
|
||||
# SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so
|
||||
# the person making the change stays signed in here. Commit BEFORE re-stamping so a failed commit
|
||||
# can't leave the cookie at a newer epoch than the DB (which would wrongly 401 THIS session).
|
||||
bump_session_epoch(user)
|
||||
bump_session_epoch(db, user)
|
||||
db.commit()
|
||||
_remember_epoch(request, user)
|
||||
log.info("Password set/changed: uid=%s", user.id)
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.db import get_db
|
||||
from app.downloads import clip as clipmod
|
||||
from app.downloads import credentials as dlcreds
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import folders
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import quota, service, storage
|
||||
from app.downloads import urlguard
|
||||
@@ -806,8 +807,14 @@ def create_link(
|
||||
|
||||
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."""
|
||||
folder = owned_or_404(db, FileFolder, folder_id, user, what="folder")
|
||||
is the user's channel to a machine of theirs: neither is a shelf to hand out. Held (FOR SHARE)
|
||||
for the rest of the transaction, so a delete of it at the same moment waits and takes the new
|
||||
link with it, rather than failing the link's foreign key."""
|
||||
owned_or_404(db, FileFolder, folder_id, user, what="folder")
|
||||
try:
|
||||
folder = folders.own_folder(db, user.id, folder_id)
|
||||
except folders.FolderError as e:
|
||||
raise refused(409, e.reason) from e
|
||||
if folder.kind != FolderKind.OWN:
|
||||
raise refused(409, "share_folder_kind")
|
||||
return folder
|
||||
|
||||
@@ -9,7 +9,7 @@ a short-lived signed grant that the media URL carries (a `<video src>` can't sen
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
@@ -22,7 +22,6 @@ from app.ratelimit import RateLimiter, client_key
|
||||
from app.security import verify_password
|
||||
from app.refusal import refused
|
||||
from app.routes._common import not_found
|
||||
from app.routes.files import MAX_LIST
|
||||
|
||||
router = APIRouter(prefix="/api/public", tags=["public"])
|
||||
|
||||
@@ -37,9 +36,6 @@ _unlock_limit = RateLimiter(max_events=10, window_seconds=300)
|
||||
# already running when it fills are still checked — as many as the route's threadpool runs at once.
|
||||
_unlock_failures = RateLimiter(max_events=50, window_seconds=300)
|
||||
|
||||
# The most files a folder link lists: as many as the owner's own Files list shows.
|
||||
_FOLDER_MAX = MAX_LIST
|
||||
|
||||
|
||||
def _link_or_404(db: Session, token: str) -> DownloadLink:
|
||||
link = linksmod.find(db, token)
|
||||
@@ -89,16 +85,6 @@ def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAs
|
||||
return _ready_job(db, db.get(DownloadJob, link.job_id))
|
||||
|
||||
|
||||
def _in_folder(link: DownloadLink, folder: FileFolder) -> tuple:
|
||||
"""What a folder link shows: the done files in its folder 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 == link.folder_id,
|
||||
DownloadJob.user_id == folder.user_id,
|
||||
DownloadJob.status == JobStatus.DONE,
|
||||
)
|
||||
|
||||
|
||||
def _link_folder(db: Session, link: DownloadLink) -> FileFolder:
|
||||
folder = db.get(FileFolder, link.folder_id) if link.folder_id is not None else None
|
||||
if folder is None:
|
||||
@@ -109,7 +95,7 @@ def _link_folder(db: Session, link: DownloadLink) -> FileFolder:
|
||||
def _folder_job(db: Session, link: DownloadLink, job_id: int) -> tuple[DownloadJob, MediaAsset]:
|
||||
folder = _link_folder(db, link)
|
||||
job = db.execute(
|
||||
select(DownloadJob).where(DownloadJob.id == job_id, *_in_folder(link, folder))
|
||||
select(DownloadJob).where(DownloadJob.id == job_id, *linksmod.in_folder(folder))
|
||||
).scalar_one_or_none()
|
||||
if job is None:
|
||||
raise refused(404, "video_gone")
|
||||
@@ -136,16 +122,9 @@ def _poster_url(
|
||||
|
||||
|
||||
def _folder_meta(db: Session, link: DownloadLink, token: str, grant: str | None) -> dict:
|
||||
"""A folder link's page: the folder's name and each ready file's own watch metadata, newest
|
||||
first. A file whose bytes are not on disk is left out, so the list never offers a dead row."""
|
||||
"""A folder link's page: the folder's name and each file's own watch metadata
|
||||
(`linksmod.folder_files`: ready, on disk, newest first)."""
|
||||
folder = _link_folder(db, link)
|
||||
rows = db.execute(
|
||||
select(DownloadJob, MediaAsset)
|
||||
.join(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
||||
.where(*_in_folder(link, folder), MediaAsset.status == "ready")
|
||||
.order_by(DownloadJob.ready_at.desc().nulls_last(), DownloadJob.id.desc())
|
||||
.limit(_FOLDER_MAX)
|
||||
).all()
|
||||
files = [
|
||||
{
|
||||
"id": job.id,
|
||||
@@ -157,8 +136,7 @@ def _folder_meta(db: Session, link: DownloadLink, token: str, grant: str | None)
|
||||
_poster_url(token, asset, grant, job.id),
|
||||
),
|
||||
}
|
||||
for job, asset in rows
|
||||
if storage.safe_abs_path(settings.files_root, asset.rel_path) is not None
|
||||
for job, asset in linksmod.folder_files(db, folder)
|
||||
]
|
||||
return {
|
||||
"needs_password": False,
|
||||
@@ -187,8 +165,13 @@ def _opened(db: Session, link: DownloadLink, token: str, grant: str | None = Non
|
||||
if link.folder_id is not None
|
||||
else _file_meta(db, link, token, grant)
|
||||
)
|
||||
link.view_count += 1
|
||||
db.flush()
|
||||
# In SQL, not `+= 1` on the loaded row: two opens at once would both write the same count.
|
||||
db.execute(
|
||||
update(DownloadLink)
|
||||
.where(DownloadLink.id == link.id)
|
||||
.values(view_count=DownloadLink.view_count + 1)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
return meta
|
||||
|
||||
|
||||
|
||||
@@ -104,9 +104,9 @@ def _wanted_grants(db: Session, user: User, body: TokenIn) -> dict[int, set[Righ
|
||||
for folder_id, rights in wanted.items():
|
||||
try:
|
||||
if Right.WRITE in rights:
|
||||
folders.upload_target(db, user.id, folder_id, hold=False)
|
||||
folders.upload_target(db, user.id, folder_id)
|
||||
else:
|
||||
folders.own_folder(db, user.id, folder_id, hold=False)
|
||||
folders.own_folder(db, user.id, folder_id)
|
||||
except folders.FolderError as e:
|
||||
raise refused(409, e.reason) from e
|
||||
return wanted
|
||||
|
||||
@@ -370,7 +370,8 @@ def _explore_cleanup_job() -> None:
|
||||
|
||||
|
||||
def _download_gc_job() -> None:
|
||||
# Retention GC for the download center: pre-expiry warnings, TTL deletion, LRU eviction.
|
||||
# Retention GC for the download center: pre-expiry warnings, TTL deletion, LRU eviction — and
|
||||
# the week-ahead warning before an API token runs out (`apitokens.warn_expiring`).
|
||||
# Pure disk/DB work (no YouTube quota).
|
||||
_job("download_gc", run_download_gc)
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ from app.models import (
|
||||
)
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.routes import tokens as tokens_routes
|
||||
from app.security import hash_token
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -114,6 +115,17 @@ def test_a_token_has_its_shape_and_a_checksum_that_refuses_a_typo():
|
||||
assert not apitokens.well_formed(flipped)
|
||||
assert not apitokens.well_formed(raw[:-1])
|
||||
assert not apitokens.well_formed("xxx_" + raw[4:])
|
||||
# A header byte past ASCII arrives as one latin-1 character: refused, never a 500.
|
||||
assert not apitokens.well_formed(raw[:-1] + "é")
|
||||
|
||||
|
||||
def test_a_non_ascii_token_is_refused_as_any_bad_one(db, client):
|
||||
# The test client re-sends a high byte as two UTF-8 bytes, which arrive as two latin-1
|
||||
# characters: one "A" fewer keeps the header a token's length, so it is the checksum that meets
|
||||
# them, not the length check.
|
||||
raw = ("Bearer sfl_" + "A" * 47 + "é").encode("latin-1")
|
||||
r = TestClient(app, headers={"authorization": raw}).get("/api/files/folders")
|
||||
assert r.status_code == 401 and r.json()["detail"]["reason"] == "not_authenticated"
|
||||
|
||||
|
||||
def test_the_hub_preset_makes_one_hub_folder_with_every_right_and_shows_the_token_once(
|
||||
@@ -248,12 +260,23 @@ def test_a_token_is_judged_by_its_grants_even_beside_a_signed_in_cookie(db, make
|
||||
assert both.json() == [] # the token may not list: its grants decide
|
||||
|
||||
|
||||
def test_a_token_works_on_the_files_routes_only(db, make_user, as_user):
|
||||
alice = make_user()
|
||||
raw = _make(as_user(alice), preset="hub")["token"]
|
||||
def _signed_in(client, email="alice@example.com") -> TestClient:
|
||||
r = client.post(
|
||||
"/auth/password-login", json={"email": email, "password": "correct horse battery"}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return client
|
||||
|
||||
|
||||
def test_a_token_works_on_the_files_routes_only(db, make_user, client):
|
||||
"""Through the real session stack: under `as_user` every route without its header answers 401
|
||||
whatever `current_user` does with a token, so these refusals would prove nothing there."""
|
||||
make_user(email="alice@example.com")
|
||||
raw = _make(_signed_in(client), preset="hub")["token"]
|
||||
m = _bearer(raw)
|
||||
assert m.get("/api/files/folders").status_code == 200
|
||||
# Making a wider token, or reading the account, takes the user's session.
|
||||
assert m.get("/api/me").json() == {"authenticated": False} # the token is no one here
|
||||
assert m.get("/api/tokens").status_code == 401
|
||||
assert m.post("/api/tokens", json={"name": "wider", "preset": "hub"}).status_code == 401
|
||||
assert m.get("/api/files/usage").status_code == 401
|
||||
@@ -296,6 +319,55 @@ def test_only_the_files_routes_take_a_token():
|
||||
}
|
||||
|
||||
|
||||
def _reset_password(db, client, alice):
|
||||
from app.routes.auth import _issue_token
|
||||
|
||||
link = _issue_token(db, alice, "reset", timedelta(hours=1))
|
||||
r = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": link, "password": "a brand new password"}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def _change_password(db, client, alice):
|
||||
r = client.post(
|
||||
"/auth/set-password",
|
||||
json={"password": "a brand new password", "current_password": "correct horse battery"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def _log_out_others(db, client, alice):
|
||||
assert client.post("/auth/logout-others").status_code == 200
|
||||
|
||||
|
||||
def _revoke_untrusted_password(db, client, alice):
|
||||
from app.routes.auth import _revoke_password
|
||||
|
||||
_revoke_password(db, alice)
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"end_sessions",
|
||||
[_reset_password, _change_password, _log_out_others, _revoke_untrusted_password],
|
||||
)
|
||||
def test_whatever_ends_every_session_ends_every_token_too(db, make_user, client, end_sessions):
|
||||
"""A token minted from a stolen session must not outlive the owner's answer to the theft."""
|
||||
alice = make_user(email="alice@example.com")
|
||||
bob = make_user(email="bob@example.com")
|
||||
raw = _make(_signed_in(client), preset="hub")["token"]
|
||||
bobs = apitokens.mint()
|
||||
db.add(ApiToken(user_id=bob.id, name="b", prefix=bobs[:12], token_hash=hash_token(bobs)))
|
||||
db.commit()
|
||||
assert _bearer(raw).get("/api/files/folders").status_code == 200
|
||||
end_sessions(db, client, alice)
|
||||
assert _bearer(raw).get("/api/files/folders").status_code == 401
|
||||
assert db.execute(select(ApiToken.user_id)).scalars().all() == [bob.id] # only hers went
|
||||
[revoked] = _audits(db, "api_token_revoke")
|
||||
assert revoked.actor_id == alice.id and "every session" in (revoked.summary or "")
|
||||
|
||||
|
||||
# --- a token that does not hold ------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -493,7 +493,7 @@ def _revoke_as_google_adoption(db, user) -> None:
|
||||
Google callback adopting an account whose email was never verified."""
|
||||
from app.routes.auth import revoke_untrusted_preset_password
|
||||
|
||||
revoke_untrusted_preset_password(user)
|
||||
revoke_untrusted_preset_password(db, user)
|
||||
db.commit()
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ a file link; a document is never served to open as a page.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
@@ -15,9 +16,9 @@ from sqlalchemy import select, text
|
||||
|
||||
from app.config import settings
|
||||
from app.db import engine
|
||||
from app.downloads import og
|
||||
from app.downloads import folders, og
|
||||
from app.downloads import probe as probemod
|
||||
from app.models import AuditLog, DownloadJob, DownloadLink, FileFolder
|
||||
from app.models import AuditLog, DownloadJob, DownloadLink, FileFolder, MediaAsset
|
||||
|
||||
_VIDEO = {
|
||||
"streams": [
|
||||
@@ -125,6 +126,12 @@ def test_only_a_folder_of_ones_own_can_be_shared(shelf, db, as_user, make_user):
|
||||
r = owner.post(f"/api/downloads/folders/{source}/links", json={})
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"]["reason"] == "share_folder_kind"
|
||||
# Nor the Hub: whatever a machine drops there would be public the moment it lands.
|
||||
hub = folders.hub_folder(db, shelf["user"].id).id
|
||||
db.commit()
|
||||
r = owner.post(f"/api/downloads/folders/{hub}/links", json={})
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"]["reason"] == "share_folder_kind"
|
||||
|
||||
stranger = as_user(make_user())
|
||||
assert (
|
||||
@@ -154,6 +161,43 @@ def test_a_password_folder_link_shows_nothing_until_unlocked(shelf, client):
|
||||
assert client.get(files[0]["file_url"]).status_code == 200
|
||||
|
||||
|
||||
def test_a_files_poster_opens_by_the_links_own_rules(shelf, db, client):
|
||||
"""The poster route on its own: a password link's poster needs the unlock grant, and a file
|
||||
outside the folder has none through it, grant or not."""
|
||||
for job_id in (shelf["inside"][0], shelf["outside"]):
|
||||
asset = db.get(MediaAsset, db.get(DownloadJob, job_id).asset_id)
|
||||
asset.poster_path = f"poster-{job_id}.jpg"
|
||||
(Path(settings.files_root) / asset.poster_path).write_bytes(b"a poster frame")
|
||||
db.commit()
|
||||
token = _link(shelf, password="open sesame")["token"]
|
||||
inside = f"/api/public/watch/{token}/files/{shelf['inside'][0]}/poster.jpg"
|
||||
assert client.get(inside).status_code == 403
|
||||
|
||||
unlocked = client.post(f"/api/public/watch/{token}/unlock", json={"password": "open sesame"})
|
||||
[entry] = [f for f in unlocked.json()["files"] if f["id"] == shelf["inside"][0]]
|
||||
assert entry["poster_url"].startswith(inside + "?g=")
|
||||
got = client.get(entry["poster_url"])
|
||||
assert got.status_code == 200 and got.content == b"a poster frame"
|
||||
grant = entry["poster_url"].split("?g=", 1)[1]
|
||||
outside = f"/api/public/watch/{token}/files/{shelf['outside']}/poster.jpg?g={grant}"
|
||||
assert client.get(outside).status_code == 404
|
||||
|
||||
|
||||
def test_every_open_counts_as_a_view(shelf, client):
|
||||
made = _link(shelf)
|
||||
for _ in range(3):
|
||||
assert client.get(f"/api/public/watch/{made['token']}").status_code == 200
|
||||
[listed] = shelf["owner"].get(f"/api/downloads/folders/{shelf['folder']}/links").json()
|
||||
assert listed["view_count"] == 3
|
||||
|
||||
|
||||
def test_the_folder_list_is_capped_like_the_owners_files_list():
|
||||
from app.downloads import links as linksmod
|
||||
from app.routes.files import MAX_LIST
|
||||
|
||||
assert linksmod.FOLDER_MAX == MAX_LIST
|
||||
|
||||
|
||||
def test_a_document_in_a_shared_folder_only_downloads(shelf, client, probe):
|
||||
probe["data"] = None
|
||||
doc = shelf["upload"]("notes.html", shelf["folder"], b"<script>1</script>")
|
||||
@@ -234,6 +278,11 @@ def test_the_unfurl_names_the_folder_and_counts_its_files_only(shelf, db):
|
||||
for name in names:
|
||||
assert not name or name not in tags
|
||||
|
||||
# A file whose bytes are gone is off the page, and so off the card's count.
|
||||
gone = db.get(MediaAsset, db.get(DownloadJob, shelf["inside"][0]).asset_id)
|
||||
(Path(settings.files_root) / gone.rel_path).unlink()
|
||||
assert "1 file " in og._watch_tags(token, db)
|
||||
|
||||
locked = _link(shelf, password="pw")["token"]
|
||||
hidden = og._watch_tags(locked, db)
|
||||
assert "Friends" not in hidden and "Password-protected folder" in hidden
|
||||
|
||||
@@ -185,3 +185,43 @@ def test_the_downgrade_writes_the_tokens_back(db, key, owner_job):
|
||||
assert db.execute(select(DownloadLink.token_hash)).scalars().all() == [
|
||||
security.hash_token(token)
|
||||
]
|
||||
|
||||
|
||||
def test_without_a_key_the_migration_keeps_the_address_opening_but_not_shown(db, owner_job, client):
|
||||
"""No key to seal with: the hash alone is kept, the link still opens, its address is gone."""
|
||||
assert not security.can_encrypt()
|
||||
owner, job_id = owner_job
|
||||
db.commit() # an open transaction's locks would hold the downgrade's ALTER TABLE forever
|
||||
cfg = Config("alembic.ini")
|
||||
command.downgrade(cfg, "0106_api_tokens")
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text("INSERT INTO download_links (job_id, token) VALUES (:j, 'an-old-token-sent')"),
|
||||
{"j": job_id},
|
||||
)
|
||||
finally:
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
(row,) = db.execute(text("SELECT token_hash, token_enc FROM download_links")).mappings().all()
|
||||
assert row["token_hash"] == security.hash_token("an-old-token-sent")
|
||||
assert row["token_enc"] is None
|
||||
assert client.get("/api/public/watch/an-old-token-sent").status_code == 200
|
||||
assert [e["url"] for e in owner.get(f"/api/downloads/{job_id}/links").json()] == [None]
|
||||
|
||||
|
||||
def test_a_downgrade_with_no_readable_copy_writes_a_fresh_token(db, key, owner_job, monkeypatch):
|
||||
"""A key since changed reads no copy: the old column gets a random token, so the link is
|
||||
revoked rather than left with no token (the column is NOT NULL there)."""
|
||||
owner, job_id = owner_job
|
||||
token = owner.post(f"/api/downloads/{job_id}/links", json={}).json()["token"]
|
||||
db.commit() # an open transaction's locks would hold the downgrade's ALTER TABLE forever
|
||||
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
|
||||
cfg = Config("alembic.ini")
|
||||
command.downgrade(cfg, "0106_api_tokens")
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
back = conn.execute(text("SELECT token FROM download_links")).scalars().all()
|
||||
finally:
|
||||
command.upgrade(cfg, "head")
|
||||
assert len(back) == 1 and back[0] and back[0] != token
|
||||
|
||||
@@ -53,6 +53,9 @@ class TestGrant:
|
||||
def test_malformed_grants_are_rejected_not_crashed(self):
|
||||
for bad in [None, "", "no-dot", "notanumber.sig"]:
|
||||
assert check_grant("tok", bad, 0) is False
|
||||
# The query string decodes as UTF-8: a signature past ASCII is just a wrong one.
|
||||
future = int(datetime.now(timezone.utc).timestamp()) + _GRANT_TTL
|
||||
assert check_grant("tok", f"{future}.é", 0) is False
|
||||
|
||||
|
||||
def _asset(**over):
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Migration 0106's downgrade data step, DB-lane: a Hub folder holding a file becomes an own folder
|
||||
that keeps the file — under a name its user's own folders do not already have (their names are
|
||||
unique per user, case-blind), and with its token gone with the tokens table."""
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import engine
|
||||
|
||||
|
||||
def test_a_hub_folder_with_a_file_comes_back_as_an_own_folder_beside_a_namesake(db, make_user):
|
||||
uid = make_user().id
|
||||
with engine.begin() as conn:
|
||||
hub = conn.execute(
|
||||
text("INSERT INTO file_folders (user_id, kind) VALUES (:u, 'hub') RETURNING id"),
|
||||
{"u": uid},
|
||||
).scalar_one()
|
||||
conn.execute(
|
||||
text("INSERT INTO file_folders (user_id, kind, name) VALUES (:u, 'own', :n)"),
|
||||
{"u": uid, "n": f"hub {hub}"}, # the name the downgrade would pick, in another case
|
||||
)
|
||||
held = conn.execute(
|
||||
text(
|
||||
"INSERT INTO download_jobs (user_id, source_kind, source_ref, profile_snapshot,"
|
||||
" status, folder_id) VALUES (:u, 'upload', 'x', '{}', 'queued', :f) RETURNING id"
|
||||
),
|
||||
{"u": uid, "f": hub},
|
||||
).scalar_one()
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO api_tokens (user_id, name, prefix, token_hash) VALUES"
|
||||
" (:u, 'hub', 'sfl_abcd1234', 'h') "
|
||||
),
|
||||
{"u": uid},
|
||||
)
|
||||
cfg = Config("alembic.ini")
|
||||
command.downgrade(cfg, "0105_job_source_key")
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
kind, name = conn.execute(
|
||||
text("SELECT kind, name FROM file_folders WHERE id = :i"), {"i": hub}
|
||||
).one()
|
||||
folder_of_job = conn.execute(
|
||||
text("SELECT folder_id FROM download_jobs WHERE id = :j"), {"j": held}
|
||||
).scalar_one()
|
||||
finally:
|
||||
command.upgrade(cfg, "head")
|
||||
assert (kind, name) == ("own", f"Hub {hub} (2)")
|
||||
assert folder_of_job == hub
|
||||
@@ -96,6 +96,9 @@ class TestDownloadFilename:
|
||||
def test_does_not_double_an_extension_already_present(self):
|
||||
assert download_filename("clip.mp4", "mp4", Path("/x/f.mp4")) == "clip.mp4"
|
||||
|
||||
def test_no_extension_is_served_without_a_trailing_dot(self):
|
||||
assert download_filename("Makefile", None, Path("/x/upload-7")) == "Makefile"
|
||||
|
||||
def test_falls_back_to_the_file_suffix_when_no_container(self):
|
||||
assert download_filename("song", None, Path("/x/f.m4a")) == "song.m4a"
|
||||
|
||||
|
||||
@@ -44,7 +44,9 @@ The installer asks for the **public URL** where the instance will be reached (ju
|
||||
local `.env` file — keep that file private. **Do not change `TOKEN_ENCRYPTION_KEY` once the app has
|
||||
stored any secret** (Google credentials, API keys, SMTP password, Plex token): those values are
|
||||
encrypted with it, and a changed key makes them unreadable. If it is ever lost or rotated, clear the
|
||||
affected rows from the `system_config` table and re-enter the secrets in the admin Configuration page,
|
||||
affected rows from the `system_config` table and re-enter the secrets in the admin Configuration page.
|
||||
Share links are kept with it too: after a key change every existing link still opens, but its
|
||||
address can no longer be shown — its owner makes a new link to share it again,
|
||||
- pulls the image and starts the app + database,
|
||||
- prints the **setup wizard URL**, which looks like `…/setup?token=…`.
|
||||
|
||||
@@ -163,7 +165,9 @@ then cannot read its own secrets.
|
||||
2. **`.env`** — in particular `TOKEN_ENCRYPTION_KEY`. Your Google client secret, SMTP password, Plex
|
||||
token and every stored refresh token are encrypted with it *inside* the database. Restore a dump
|
||||
against a different key and those values are lost — you would have to clear the affected rows from
|
||||
`system_config` and re-enter every secret by hand. Keep `.env` beside the dump, and just as private.
|
||||
`system_config` and re-enter every secret by hand — and no existing share link's address can be
|
||||
shown again (the links still open; their owners make new ones to share). Keep `.env` beside the
|
||||
dump, and just as private.
|
||||
3. **Downloaded media** *(only if you use Files)* — the `siftlode_files` volume, or
|
||||
whatever `FILES_HOST_PATH` points at. Not needed to bring the instance back; the jobs and their
|
||||
metadata are in the database, the files themselves are not.
|
||||
|
||||
@@ -17,7 +17,7 @@ import { retryConnect } from "./retryConnect";
|
||||
|
||||
const RUNS = 5;
|
||||
const OUT = "e2e/.speed/latest.json";
|
||||
const CONTAINER = process.env.E2E_DB_CONTAINER || "siftlode-db-1";
|
||||
const CONTAINER = process.env.E2E_API_CONTAINER || "siftlode-api-1";
|
||||
|
||||
const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] ?? NaN;
|
||||
|
||||
@@ -32,20 +32,33 @@ const scriptBytes = (page: Page) =>
|
||||
|
||||
/** Public share links on the local stack that could play: no password, not expired, asset ready.
|
||||
* The page itself is the judge of the last step (its file must be on THIS disk — a localdev DB
|
||||
* seeded from prod lists assets whose files never came along), so the caller probes them. */
|
||||
* seeded from prod lists assets whose files never came along), so the caller probes them. Read
|
||||
* through the API container: the database keeps a link's token only hashed and encrypted, and only
|
||||
* the app holds the key to read it back (`links.recover`). */
|
||||
const SHARE_TOKENS_PY = `
|
||||
from sqlalchemy import select
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import links
|
||||
from app.models import DownloadJob, DownloadLink, MediaAsset
|
||||
with SessionLocal() as db:
|
||||
for link in db.execute(
|
||||
select(DownloadLink)
|
||||
.join(DownloadJob, DownloadJob.id == DownloadLink.job_id)
|
||||
.join(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
||||
.where(DownloadLink.password_hash.is_(None), MediaAsset.status == "ready")
|
||||
.order_by(DownloadLink.id)
|
||||
).scalars():
|
||||
token = None if links.is_expired(link) else links.recover(link)
|
||||
if token:
|
||||
print(token)
|
||||
`;
|
||||
|
||||
function shareTokens(): string[] {
|
||||
const sql =
|
||||
"select l.token from download_links l join download_jobs j on j.id = l.job_id" +
|
||||
" join media_assets a on a.id = j.asset_id" +
|
||||
" where l.password_hash is null and (l.expires_at is null or l.expires_at > now())" +
|
||||
" and a.status = 'ready' order by l.id";
|
||||
// Not caught: a database this cannot reach (no docker, a renamed container) is a failed
|
||||
// Not caught: an API container this cannot reach (no docker, a renamed container) is a failed
|
||||
// measurement, not "no playable share link on the local stack".
|
||||
const out = execFileSync(
|
||||
"docker",
|
||||
["exec", CONTAINER, "psql", "-U", "siftlode", "-d", "siftlode", "-tAc", sql],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const out = execFileSync("docker", ["exec", CONTAINER, "python", "-c", SHARE_TOKENS_PY], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
return out
|
||||
.split("\n")
|
||||
.map((t) => t.trim())
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
"playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally.",
|
||||
"maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the Feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue.",
|
||||
"explore_cleanup": "Removes channels you explored but never subscribed to (and their videos) after a grace period, so curiosity browsing doesn't pile up in the catalogue. If it stops, abandoned explored channels linger.",
|
||||
"download_gc": "Deletes downloaded files past their retention window, reclaims space no one is holding anymore, and warns owners before a shared file expires. If it stops, old downloads pile up and disk space isn't freed.",
|
||||
"download_gc": "Deletes downloaded files past their retention window, reclaims space no one is holding anymore, and warns owners before a shared file or an API token expires. If it stops, old downloads pile up, disk space isn't freed, and a token runs out unannounced.",
|
||||
"plex_sync": "Mirrors the enabled Plex libraries into the app's catalogue — the metadata behind Plex browsing, search, filters and the info pages (genres, cast, ratings, artwork). If it stops, newly added or changed Plex titles don't appear until the next sync.",
|
||||
"plex_watch_sync": "Pulls recent Plex watch changes (finished episodes, resume positions) into Siftlode and re-pushes any local watch changes that didn't reach Plex. The cheap two-way keep-in-sync between full reconciles. If it stops, watched/resume made in the Plex app takes longer to show here.",
|
||||
"plex_watch_reconcile": "A full watch-state reconcile that rescans every library — catches what the incremental pass can't, notably an episode un-marked as watched on the Plex side. Heavier, so it runs about once a day. If it stops, un-watches done in Plex aren't mirrored back.",
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
"playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan.",
|
||||
"maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a Feedből, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak.",
|
||||
"explore_cleanup": "A türelmi idő után eltávolítja a felfedezett, de nem követett csatornákat (és videóikat), hogy a kíváncsiság-böngészés ne halmozódjon a katalógusban. Ha leáll, az elhagyott felfedezett csatornák megmaradnak.",
|
||||
"download_gc": "Törli a megőrzési időn túli letöltéseket, felszabadítja a már senki által nem használt helyet, és a lejárat előtt figyelmezteti a tulajdonost. Ha leáll, a régi letöltések felhalmozódnak, és a tárhely nem szabadul fel.",
|
||||
"download_gc": "Törli a megőrzési időn túli letöltéseket, felszabadítja a már senki által nem használt helyet, és figyelmezteti a tulajdonost, mielőtt egy megosztott fájl vagy egy API-token lejár. Ha leáll, a régi letöltések felhalmozódnak, a tárhely nem szabadul fel, és a token szó nélkül jár le.",
|
||||
"plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infóoldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő szinkron után jelennek meg.",
|
||||
"plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes egyeztetések között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.",
|
||||
"plex_watch_reconcile": "A megtekintési állapot teljes egyeztetése, amely minden könyvtárat újraszkennel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben levett megtekintve jel nem tükröződik vissza.",
|
||||
|
||||
Reference in New Issue
Block a user