feat(files): share a folder by link — its page lists what is in it, plays a video above the list

A /watch link may now open one of its owner's own folders (migration 0108: folder_id beside job_id,
exactly one set; a folder's links go with it). The page shows what is in the folder when it is
opened, newest first: a video or a song plays in the same player a file link has, above the list,
each keeping its own resume point; a document only downloads, never opens as a page. A file moved
out stops opening through the link at its next request. Password, expiry and stream-only work as on
a file link; the unfurl names the folder and counts its files, never their names. Source folders
and the Hub cannot be shared.

The folder menu gets "Copy link" and "Share with a link…" (the Share dialog's link half); the rail
marks a folder a live link opens.

Two fixes in the Share dialog on the way: revoking the last link no longer mints a new one at once,
and the new-link form goes back to downloadable after making a link.
This commit is contained in:
2026-10-15 02:23:24 +02:00
parent 90567718b2
commit 86712b6b0e
33 changed files with 1880 additions and 321 deletions
@@ -0,0 +1,40 @@
"""A share link may open a folder
Revision ID: 0108_folder_links
Revises: 0107_link_token_at_rest
Create Date: 2026-10-15
A `/watch` link opened one file. Now it may open one of its owner's own folders instead: the link's
page lists what is in the folder when it is opened. `folder_id` sits beside `job_id`, exactly one of
the two set; a folder's links go with the folder.
The downgrade has nowhere to keep a folder link, so it deletes them: they stop opening.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0108_folder_links"
down_revision: str | None = "0107_link_token_at_rest"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column(
"download_links",
sa.Column("folder_id", sa.Integer(), sa.ForeignKey("file_folders.id", ondelete="CASCADE")),
)
op.create_index("ix_download_links_folder_id", "download_links", ["folder_id"])
op.alter_column("download_links", "job_id", nullable=True)
op.create_check_constraint(
"ck_download_link_target", "download_links", "(job_id IS NULL) <> (folder_id IS NULL)"
)
def downgrade() -> None:
op.execute("DELETE FROM download_links WHERE folder_id IS NOT NULL")
op.drop_constraint("ck_download_link_target", "download_links", type_="check")
op.alter_column("download_links", "job_id", nullable=False)
op.drop_index("ix_download_links_folder_id", table_name="download_links")
op.drop_column("download_links", "folder_id")
+38 -3
View File
@@ -11,12 +11,14 @@ 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.models import DownloadJob, MediaAsset
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)
@@ -77,18 +79,21 @@ def _watch_tags(token: str, db: Session) -> str | None:
if link is None or linksmod.is_expired(link):
return None
url = f"{settings.app_base}/watch/{token}"
folder = link.folder_id is not None
if link.password_hash:
# Password-gated: don't leak the title/thumbnail in the unfurl.
return "\n ".join(
[
"<title>Siftlode</title>",
_tag("og:title", "Password-protected video"),
_tag("og:title", "Password-protected " + ("folder" if folder else "video")),
_tag("og:description", "Shared via Siftlode"),
_tag("og:type", "video.other"),
_tag("og:type", "website" if folder else "video.other"),
_tag("og:url", url),
_tag("og:site_name", "Siftlode"),
]
)
if folder:
return _folder_tags(db, link, url)
job = db.get(DownloadJob, link.job_id)
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
if job is None or asset is None or asset.status != "ready":
@@ -146,6 +151,36 @@ def _watch_tags(token: str, db: Session) -> str | None:
return "\n ".join(tags)
def _folder_tags(db: Session, link: DownloadLink, url: str) -> str | None:
"""A folder link's card: the folder's name and how many files it holds — never their names,
which the page shows only to whoever opens it."""
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()
title = folder.name.strip()
about = f"{count} file{'' if count == 1 else 's'} · Shared via Siftlode"
return "\n ".join(
[
f"<title>{html.escape(title)}</title>",
_tag("og:title", title),
_tag("og:description", about),
_tag("og:type", "website"),
_tag("og:url", url),
_tag("og:site_name", "Siftlode"),
_tag("twitter:title", title, attr="name"),
_tag("twitter:description", about, attr="name"),
_tag("twitter:card", "summary", attr="name"),
]
)
def render_watch_html(token: str, page_html: Path, db: Session) -> str | None:
"""Return the watch page (watch.html) with the OG block replaced for this token, or None to
serve the generic page unchanged (invalid/expired/not-ready link, or a template without the
+17 -8
View File
@@ -1129,22 +1129,31 @@ class DownloadQuota(Base, UpdatedAtMixin):
class DownloadLink(Base, TimestampMixin):
"""A public capability link to one download — the "share by link" surface.
"""A public capability link to one download, or to one of its owner's own folders — the "share
by link" surface.
Anyone holding the unguessable token can watch the file on the login-free `/watch/{token}`
player page (Google-Drive style). The token itself is never stored as written: a link is found
by its SHA-256 (`token_hash`), and `token_enc` is the Fernet-encrypted copy its owner copies the
link from again — absent without a key (`downloads/links.py`). Optional per-link controls: `expires_at`, `allow_download`
(stream-only vs downloadable), and a `password_hash` (argon2, like a user password). Revoke =
delete the row. Distinct from `DownloadShare`, which grants a *registered* user access via the
in-app "Shared with me" list."""
player page (Google-Drive style); a folder link opens the folder's list there, with what is in
the folder at the time it is opened. The token itself is never stored as written: a link is
found by its SHA-256 (`token_hash`), and `token_enc` is the Fernet-encrypted copy its owner
copies the link from again — absent without a key (`downloads/links.py`). Optional per-link
controls: `expires_at`, `allow_download` (stream-only vs downloadable), and a `password_hash`
(argon2, like a user password). Revoke = delete the row. Distinct from `DownloadShare`, which
grants a *registered* user access via the in-app "Shared with me" list."""
__tablename__ = "download_links"
__table_args__ = (
# A link opens exactly one thing: a file or a folder.
CheckConstraint("(job_id IS NULL) <> (folder_id IS NULL)", name="ck_download_link_target"),
)
id: Mapped[int] = mapped_column(primary_key=True)
job_id: Mapped[int] = mapped_column(
job_id: Mapped[int | None] = mapped_column(
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
)
folder_id: Mapped[int | None] = mapped_column(
ForeignKey("file_folders.id", ondelete="CASCADE"), index=True
)
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
token_enc: Mapped[str | None] = mapped_column(Text) # Fernet ciphertext — never returned/logged
created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
+66 -26
View File
@@ -30,10 +30,12 @@ from app.downloads import edit as editmod
from app.downloads import links as linksmod
from app.downloads import quota, service, storage
from app.downloads import urlguard
from app.downloads.folders import FolderKind
from app.downloads.status import FINISHED, NOT_REFETCHABLE, JobStatus
from app.models import (
DownloadJob,
DownloadLink,
FileFolder,
DownloadProfile,
DownloadQuota,
DownloadShare,
@@ -717,8 +719,12 @@ def _own_link(db: Session, user: User, link_id: int) -> DownloadLink:
link = db.get(DownloadLink, link_id)
if link is None:
raise not_found("link")
job = db.get(DownloadJob, link.job_id)
if job is None or job.user_id != user.id:
target = (
db.get(DownloadJob, link.job_id)
if link.job_id is not None
else db.get(FileFolder, link.folder_id)
)
if target is None or target.user_id != user.id:
raise not_found("link")
return link
@@ -731,21 +737,17 @@ def _link_expiry(days: int | None) -> datetime | None:
return datetime.now(timezone.utc) + timedelta(days=min(days, 3650))
def _links_of(db: Session, *where) -> list[dict]:
rows = db.execute(select(DownloadLink).where(*where).order_by(DownloadLink.id.desc()))
return [linksmod.owner_view(row) for row in rows.scalars()]
@router.get("/{job_id}/links")
def list_links(
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> list[dict]:
owned_or_404(db, DownloadJob, job_id, user, what="download")
rows = (
db.execute(
select(DownloadLink)
.where(DownloadLink.job_id == job_id)
.order_by(DownloadLink.id.desc())
)
.scalars()
.all()
)
return [linksmod.owner_view(row) for row in rows]
return _links_of(db, DownloadLink.job_id == job_id)
class CreateLinkIn(BaseModel):
@@ -759,20 +761,15 @@ class CreateLinkIn(BaseModel):
expires_days: int | None = None
@router.post("/{job_id}/links")
def create_link(
job_id: int,
body: CreateLinkIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
if job.status != JobStatus.DONE or job.asset_id is None:
raise refused(409, "share_unfinished")
def _new_link(
db: Session, user: User, body: CreateLinkIn, **target: int
) -> tuple[DownloadLink, str]:
"""Make a link to `target` (`job_id=` or `folder_id=`, already checked to be the user's), and
the token it is opened by — which is in hand only now."""
pw = body.password.strip()
token = linksmod.new_token()
link = DownloadLink(
job_id=job_id,
**target,
created_by=user.id,
allow_download=body.allow_download,
password_hash=hash_password(pw) if pw else None,
@@ -787,10 +784,53 @@ def create_link(
audit.AuditAction.DOWNLOAD_LINK_CREATE,
target_type="download_link",
target_id=link.id,
after={"job_id": job_id, **_link_audit(link)},
after={**target, **_link_audit(link)},
)
return link, token
@router.post("/{job_id}/links")
def create_link(
job_id: int,
body: CreateLinkIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
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)
db.commit()
return linksmod.owner_view(link, token=token)
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")
if folder.kind != FolderKind.OWN:
raise refused(409, "share_folder_kind")
return folder
@router.get("/folders/{folder_id}/links")
def list_folder_links(
folder_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> list[dict]:
owned_or_404(db, FileFolder, folder_id, user, what="folder")
return _links_of(db, DownloadLink.folder_id == folder_id)
@router.post("/folders/{folder_id}/links")
def create_folder_link(
folder_id: int,
body: CreateLinkIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
_shareable_folder(db, user, folder_id)
link, token = _new_link(db, user, body, folder_id=folder_id)
db.commit()
db.refresh(link)
return linksmod.owner_view(link, token=token)
@@ -856,7 +896,7 @@ def revoke_link(
audit.AuditAction.DOWNLOAD_LINK_REVOKE,
target_type="download_link",
target_id=link_id,
after={"job_id": link.job_id},
after={"job_id": link.job_id} if link.job_id is not None else {"folder_id": link.folder_id},
)
db.commit()
return {"ok": True, "deleted": link_id}
+19 -3
View File
@@ -47,6 +47,7 @@ from app.mailtext import lang_for_user
from app.models import (
DISPLAY_TEXT_MAX,
DownloadJob,
DownloadLink,
DownloadShare,
FileFolder,
LibraryState,
@@ -352,7 +353,7 @@ def readable_job(db: Session, caller: FilesCaller, job_id: int) -> DownloadJob:
# --- folders -------------------------------------------------------------------------------
def _serialize_folder(folder: FileFolder, count: int, size: int) -> dict:
def _serialize_folder(folder: FileFolder, count: int, size: int, linked: bool = False) -> dict:
return {
"id": folder.id,
"kind": folder.kind,
@@ -363,9 +364,23 @@ def _serialize_folder(folder: FileFolder, count: int, size: int) -> dict:
"count": count,
"size_bytes": size,
"created_at": iso(folder.created_at),
# A live public link opens it: whatever is moved in is shown to whoever holds the link.
"has_link": linked,
}
def _linked(db: Session, folder_ids: list[int]) -> set[int]:
"""Which of these folders a live public link opens (one not past its expiry)."""
now = datetime.now(timezone.utc)
rows = db.execute(
select(DownloadLink.folder_id).where(
DownloadLink.folder_id.in_(folder_ids),
or_(DownloadLink.expires_at.is_(None), DownloadLink.expires_at > now),
)
).scalars()
return {folder_id for folder_id in rows if folder_id is not None}
@router.get("/folders")
def list_folders(
caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db)
@@ -388,7 +403,8 @@ def list_folders(
.group_by(FileFolder.id)
.order_by(FileFolder.kind.desc(), FileFolder.source_key, FileFolder.name, FileFolder.id)
).all()
return [_serialize_folder(f, count, int(size)) for f, count, size in rows]
linked = _linked(db, [f.id for f, _, _ in rows])
return [_serialize_folder(f, count, int(size), f.id in linked) for f, count, size in rows]
class FolderIn(BaseModel):
@@ -471,7 +487,7 @@ def update_folder(
)
db.commit()
count, size = _folder_totals(db, folder.id)
return _serialize_folder(folder, count, size)
return _serialize_folder(folder, count, size, folder.id in _linked(db, [folder.id]))
def _folder_totals(db: Session, folder_id: int) -> tuple[int, int]:
+165 -51
View File
@@ -9,6 +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.orm import Session
from app.config import settings
@@ -16,11 +17,12 @@ from app.db import get_db
from app.downloads import links as linksmod
from app.downloads import storage
from app.downloads.status import JobStatus
from app.models import DownloadJob, DownloadLink, MediaAsset, User
from app.models import DownloadJob, DownloadLink, FileFolder, MediaAsset, User
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"])
@@ -35,6 +37,9 @@ _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)
@@ -54,8 +59,7 @@ def _being_made(db: Session, job: DownloadJob) -> bool:
return owner is not None and not owner.is_suspended
def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAsset]:
job = db.get(DownloadJob, link.job_id)
def _ready_job(db: Session, job: DownloadJob | None) -> tuple[DownloadJob, MediaAsset]:
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
if (
job is not None
@@ -78,36 +82,127 @@ def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAs
return job, asset
def _ready_asset(db: Session, link: DownloadLink) -> MediaAsset:
return _ready_target(db, link)[1]
def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAsset]:
"""A file link's file. A folder link names no one file: its files are asked for by id."""
if link.job_id is None:
raise refused(404, "video_gone")
return _ready_job(db, db.get(DownloadJob, link.job_id))
def _file_url(token: str, grant: str | None = None) -> str:
base = f"/api/public/watch/{token}/file"
return f"{base}?g={grant}" if grant else base
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 _poster_url(token: str, asset, grant: str | None = None) -> str | None:
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:
raise refused(404, "link_gone")
return folder
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))
).scalar_one_or_none()
if job is None:
raise refused(404, "video_gone")
return _ready_job(db, job)
def _base(token: str, job_id: int | None) -> str:
"""A file link's file lives under its token; a folder link's files, each under its id."""
return f"/api/public/watch/{token}" + (f"/files/{job_id}" if job_id is not None else "")
def _file_url(token: str, grant: str | None = None, job_id: int | None = None) -> str:
url = f"{_base(token, job_id)}/file"
return f"{url}?g={grant}" if grant else url
def _poster_url(
token: str, asset: MediaAsset, grant: str | None = None, job_id: int | None = None
) -> str | None:
if not asset.poster_path:
return None
base = f"/api/public/watch/{token}/poster.jpg"
return f"{base}?g={grant}" if grant else base
url = f"{_base(token, job_id)}/poster.jpg"
return f"{url}?g={grant}" if grant else 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."""
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,
**linksmod.public_meta(
link,
job,
asset,
_file_url(token, grant, job.id),
_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
]
return {
"needs_password": False,
"kind": "folder",
"name": folder.name,
"allow_download": link.allow_download,
"files": files,
}
def _file_meta(db: Session, link: DownloadLink, token: str, grant: str | None) -> dict:
job, asset = _ready_target(db, link)
return {
"needs_password": False,
"kind": "file",
**linksmod.public_meta(
link, job, asset, _file_url(token, grant), _poster_url(token, asset, grant)
),
}
def _opened(db: Session, link: DownloadLink, token: str, grant: str | None = None) -> dict:
"""The page an open link shows, counted as one view (the handler commits the count)."""
meta = (
_folder_meta(db, link, token, grant)
if link.folder_id is not None
else _file_meta(db, link, token, grant)
)
link.view_count += 1
db.flush()
return meta
@router.get("/watch/{token}")
def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
"""Public metadata for the watch page. For a password link, returns only `needs_password`
until the viewer unlocks it (no title/thumbnail leak)."""
"""Public metadata for the watch page: one file's, or a folder's list. For a password link,
returns only `needs_password` until the viewer unlocks it (no title/thumbnail leak)."""
link = _link_or_404(db, token)
if link.password_hash:
return {"needs_password": True}
job, asset = _ready_target(db, link)
link.view_count += 1
# Which kind it opens, for the password prompt's title — never what is in it.
return {"needs_password": True, "kind": "folder" if link.folder_id is not None else "file"}
meta = _opened(db, link, token)
db.commit()
return {
"needs_password": False,
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
}
return meta
class WatchUnlockIn(BaseModel):
@@ -118,7 +213,7 @@ class WatchUnlockIn(BaseModel):
def watch_unlock(
token: str, body: WatchUnlockIn, request: Request, db: Session = Depends(get_db)
) -> dict:
"""Exchange the link password for a signed grant carried by the media URL."""
"""Exchange the link password for a signed grant carried by the media URLs."""
# The link first: a token naming none must leave no limiter entry behind, or posting made-up tokens
# would grow the limiter without bound.
link = _link_or_404(db, token)
@@ -127,36 +222,27 @@ def watch_unlock(
raise refused(429, "too_many_attempts")
if not link.password_hash:
# Not a password link — nothing to unlock; just hand back the plain meta.
job, asset = _ready_target(db, link)
return {
"ok": True,
"needs_password": False,
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
}
meta = _opened(db, link, token)
db.commit()
return {"ok": True, **meta}
if not verify_password(body.password, link.password_hash):
_unlock_failures.allow(token)
raise refused(403, "wrong_share_password")
job, asset = _ready_target(db, link)
link.view_count += 1
db.commit()
grant = linksmod.make_grant(token, link.password_version)
return {
"ok": True,
"needs_password": False,
**linksmod.public_meta(
link, job, asset, _file_url(token, grant), _poster_url(token, asset, grant)
),
}
meta = _opened(db, link, token, grant)
db.commit()
return {"ok": True, **meta}
@router.get("/watch/{token}/file")
def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
"""Range-aware stream of the shared file. Inline by default (plays in the page); an
`allow_download` link serves it as an attachment. Password links require a valid grant."""
link = _link_or_404(db, token)
def _granted(link: DownloadLink, token: str, g: str | None) -> None:
"""A password link's files want the grant its unlock handed out."""
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
raise refused(403, "share_password_required")
asset = _ready_asset(db, link)
def _serve(link: DownloadLink, asset: MediaAsset) -> FileResponse:
"""Range-aware stream of a shared file. Inline by default (plays in the page); an
`allow_download` link serves it as an attachment."""
path = storage.safe_abs_path(settings.files_root, asset.rel_path)
if path is None:
raise refused(404, "video_gone")
@@ -183,17 +269,45 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
)
@router.get("/watch/{token}/poster.jpg")
def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db)):
"""The generated poster frame for a shared thumbnail-less video (used as the page/video poster
and the link-preview image). Password links require a valid grant, like the file."""
link = _link_or_404(db, token)
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
raise refused(403, "share_password_required")
job, asset = _ready_target(db, link)
def _poster(asset: MediaAsset) -> FileResponse:
if not asset.poster_path:
raise not_found("poster")
path = storage.safe_abs_path(settings.files_root, asset.poster_path)
if path is None:
raise not_found("poster")
return FileResponse(path, media_type="image/jpeg")
@router.get("/watch/{token}/file")
def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
"""A file link's file. Password links require a valid grant."""
link = _link_or_404(db, token)
_granted(link, token, g)
return _serve(link, _ready_target(db, link)[1])
@router.get("/watch/{token}/poster.jpg")
def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db)):
"""The generated poster frame for a shared thumbnail-less video (used as the page/video poster
and the link-preview image). Password links require a valid grant, like the file."""
link = _link_or_404(db, token)
_granted(link, token, g)
return _poster(_ready_target(db, link)[1])
@router.get("/watch/{token}/files/{job_id}/file")
def watch_folder_file(token: str, job_id: int, g: str | None = None, db: Session = Depends(get_db)):
"""One file of a folder link's folder, while it is in it. Password links require a grant."""
link = _link_or_404(db, token)
_granted(link, token, g)
return _serve(link, _folder_job(db, link, job_id)[1])
@router.get("/watch/{token}/files/{job_id}/poster.jpg")
def watch_folder_poster(
token: str, job_id: int, g: str | None = None, db: Session = Depends(get_db)
):
"""The poster frame of one file of a folder link's folder, under the same terms as the file."""
link = _link_or_404(db, token)
_granted(link, token, g)
return _poster(_folder_job(db, link, job_id)[1])
+278
View File
@@ -0,0 +1,278 @@
"""A `/watch` link to one of its owner's own folders (routes/downloads.py, routes/public.py), DB-lane.
The link shows what is in the folder when it is opened: a file moved in appears, one moved out stops
opening at its next request. Only an own folder can be shared — a source folder lets its files go on
its own clock, the Hub is the channel to a machine. A password, an expiry and stream-only work as on
a file link; a document is never served to open as a page.
"""
from datetime import datetime, timedelta, timezone
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import select, text
from app.config import settings
from app.db import engine
from app.downloads import og
from app.downloads import probe as probemod
from app.models import AuditLog, DownloadJob, DownloadLink, FileFolder
_VIDEO = {
"streams": [
{"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"},
{"codec_type": "audio", "codec_name": "aac"},
],
"format": {"format_name": "mov,mp4", "duration": "5"},
}
@pytest.fixture
def probe(monkeypatch):
"""What ffprobe says the next upload is: a phone-safe video, or (None) a file it cannot read."""
said = {"data": _VIDEO}
monkeypatch.setattr(probemod, "probe_json", lambda _p: said["data"])
return said
@pytest.fixture
def shelf(as_user, make_user, tmp_path, monkeypatch, upload_headers, probe):
"""An owner with an own folder "Friends" holding two videos, a third video in their Uploads, and
a function to upload more."""
monkeypatch.setattr(settings, "files_root", str(tmp_path))
user = make_user()
owner = as_user(user)
folder = owner.post("/api/files/folders", json={"name": "Friends"}).json()
def upload(name: str, folder_id: int | None = None, body: bytes = b"fake-video") -> int:
r = owner.post(
"/api/files/upload", content=body, headers=upload_headers(name, folder=folder_id)
)
assert r.status_code == 200, r.text
return r.json()["id"]
inside = [upload("a.mp4", folder["id"]), upload("b.mp4", folder["id"])]
outside = upload("c.mp4")
return {
"user": user,
"owner": owner,
"folder": folder["id"],
"inside": inside,
"outside": outside,
"upload": upload,
}
def _link(shelf, **body) -> dict:
r = shelf["owner"].post(f"/api/downloads/folders/{shelf['folder']}/links", json=body)
assert r.status_code == 200, r.text
return r.json()
def _ids(client, token: str) -> list[int]:
meta = client.get(f"/api/public/watch/{token}").json()
return [f["id"] for f in meta["files"]]
def test_a_folder_link_lists_what_is_in_the_folder(shelf, client):
token = _link(shelf)["token"]
meta = client.get(f"/api/public/watch/{token}").json()
assert meta["kind"] == "folder"
assert meta["name"] == "Friends"
assert sorted(f["id"] for f in meta["files"]) == sorted(shelf["inside"])
# Each one plays and downloads through the link, under its own id.
for f in meta["files"]:
assert f["file_url"] == f"/api/public/watch/{token}/files/{f['id']}/file"
served = client.get(f["file_url"])
assert served.status_code == 200
assert served.content == b"fake-video"
def test_a_file_outside_the_folder_does_not_open_through_it(shelf, client):
token = _link(shelf)["token"]
r = client.get(f"/api/public/watch/{token}/files/{shelf['outside']}/file")
assert r.status_code == 404
# Nor does the folder link open as a file link.
assert client.get(f"/api/public/watch/{token}/file").status_code == 404
def test_the_link_follows_the_folder_as_files_move(shelf, client):
token = _link(shelf)["token"]
owner, moved_out = shelf["owner"], shelf["inside"][0]
other = owner.post("/api/files/folders", json={"name": "Other"}).json()["id"]
def move(job_id: int, folder_id: int) -> None:
r = owner.patch(f"/api/files/{job_id}", json={"folder_id": folder_id})
assert r.status_code == 200, r.text
move(shelf["outside"], shelf["folder"])
move(moved_out, other)
assert sorted(_ids(client, token)) == sorted([shelf["inside"][1], shelf["outside"]])
assert client.get(f"/api/public/watch/{token}/files/{moved_out}/file").status_code == 404
url = f"/api/public/watch/{token}/files/{shelf['outside']}/file"
assert client.get(url).status_code == 200
def test_only_a_folder_of_ones_own_can_be_shared(shelf, db, as_user, make_user):
owner = shelf["owner"]
source = db.execute(
select(FileFolder.id).where(
FileFolder.user_id == shelf["user"].id, FileFolder.kind == "source"
)
).scalar_one()
r = owner.post(f"/api/downloads/folders/{source}/links", json={})
assert r.status_code == 409
assert r.json()["detail"]["reason"] == "share_folder_kind"
stranger = as_user(make_user())
assert (
stranger.post(f"/api/downloads/folders/{shelf['folder']}/links", json={}).status_code == 404
)
assert stranger.get(f"/api/downloads/folders/{shelf['folder']}/links").status_code == 404
link_id = _link(shelf)["id"]
patch = stranger.patch(f"/api/downloads/links/{link_id}", json={"allow_download": False})
assert patch.status_code == 404
assert stranger.delete(f"/api/downloads/links/{link_id}").status_code == 404
def test_a_password_folder_link_shows_nothing_until_unlocked(shelf, client):
token = _link(shelf, password="open sesame")["token"]
assert client.get(f"/api/public/watch/{token}").json() == {
"needs_password": True,
"kind": "folder",
}
bare = f"/api/public/watch/{token}/files/{shelf['inside'][0]}/file"
assert client.get(bare).status_code == 403
unlocked = client.post(f"/api/public/watch/{token}/unlock", json={"password": "open sesame"})
assert unlocked.status_code == 200
files = unlocked.json()["files"]
assert len(files) == 2
assert all("?g=" in f["file_url"] for f in files)
assert client.get(files[0]["file_url"]).status_code == 200
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>")
token = _link(shelf, allow_download=False)["token"]
assert doc in _ids(client, token)
served = client.get(f"/api/public/watch/{token}/files/{doc}/file")
assert served.status_code == 200
assert served.headers["content-type"].startswith("application/octet-stream")
assert "attachment" in served.headers["content-disposition"].lower()
def test_a_stream_only_folder_link_plays_inline(shelf, client):
token = _link(shelf, allow_download=False)["token"]
meta = client.get(f"/api/public/watch/{token}").json()
assert meta["allow_download"] is False
served = client.get(meta["files"][0]["file_url"])
assert "inline" in served.headers["content-disposition"].lower()
def test_an_expired_or_deleted_folder_link_is_gone(shelf, client, db):
made = _link(shelf)
db.get(DownloadLink, made["id"]).expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
db.commit()
assert client.get(f"/api/public/watch/{made['token']}").status_code == 404
token = _link(shelf)["token"]
assert shelf["owner"].delete(f"/api/files/folders/{shelf['folder']}").status_code == 200
assert client.get(f"/api/public/watch/{token}").status_code == 404
db.expire_all()
assert db.execute(select(DownloadLink)).scalars().all() == []
def test_the_folder_list_marks_a_folder_a_live_link_opens(shelf, db):
owner = shelf["owner"]
def marked() -> dict[int, bool]:
return {f["id"]: f["has_link"] for f in owner.get("/api/files/folders").json()}
assert marked()[shelf["folder"]] is False
made = _link(shelf)
assert marked()[shelf["folder"]] is True
# A rename answers with the mark too, so the rail does not lose it.
renamed = owner.patch(f"/api/files/folders/{shelf['folder']}", json={"name": "Pals"}).json()
assert renamed["has_link"] is True
db.get(DownloadLink, made["id"]).expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
db.commit()
assert marked()[shelf["folder"]] is False
live = _link(shelf)
assert owner.delete(f"/api/downloads/links/{live['id']}").status_code == 200
assert marked()[shelf["folder"]] is False
assert not any(marked()[f] for f in marked() if f != shelf["folder"])
def test_making_and_revoking_a_folder_link_is_audited(shelf, db):
made = _link(shelf)
assert shelf["owner"].delete(f"/api/downloads/links/{made['id']}").status_code == 200
db.expire_all()
rows = {
a.action: a.after
for a in db.execute(
select(AuditLog).where(AuditLog.action.like("download_link_%"))
).scalars()
}
assert rows["download_link_create"]["folder_id"] == shelf["folder"]
assert "job_id" not in rows["download_link_create"]
assert rows["download_link_revoke"] == {"folder_id": shelf["folder"]}
def test_the_unfurl_names_the_folder_and_counts_its_files_only(shelf, db):
token = _link(shelf)["token"]
tags = og._watch_tags(token, db)
assert "Friends" in tags
assert "2 files" in tags
names = db.execute(
select(DownloadJob.display_name).where(DownloadJob.id.in_(shelf["inside"]))
).scalars()
for name in names:
assert not name or name not in tags
locked = _link(shelf, password="pw")["token"]
hidden = og._watch_tags(locked, db)
assert "Friends" not in hidden and "Password-protected folder" in hidden
assert 'content="website"' in hidden and "video.other" not in hidden
def test_the_downgrade_drops_folder_links_and_keeps_file_links(shelf, db, client):
_link(shelf)
file_link = shelf["owner"].post(f"/api/downloads/{shelf['outside']}/links", json={}).json()
db.commit() # an open transaction's locks would hold the downgrade's ALTER TABLE forever
cfg = Config("alembic.ini")
command.downgrade(cfg, "0107_link_token_at_rest")
try:
with engine.connect() as conn:
left = conn.execute(text("SELECT job_id FROM download_links")).scalars().all()
finally:
command.upgrade(cfg, "head")
assert left == [shelf["outside"]]
assert client.get(f"/api/public/watch/{file_link['token']}").status_code == 200
def test_a_password_file_link_says_it_opens_a_file(shelf, client):
made = (
shelf["owner"]
.post(f"/api/downloads/{shelf['outside']}/links", json={"password": "pw"})
.json()
)
meta = client.get(f"/api/public/watch/{made['token']}").json()
assert meta == {"needs_password": True, "kind": "file"}
def test_a_file_whose_bytes_are_gone_is_not_offered(shelf, client, db):
from pathlib import Path
from app.models import MediaAsset
token = _link(shelf)["token"]
gone = shelf["inside"][0]
asset = db.get(MediaAsset, db.get(DownloadJob, gone).asset_id)
(Path(settings.files_root) / asset.rel_path).unlink()
assert _ids(client, token) == [shelf["inside"][1]]
assert client.get(f"/api/public/watch/{token}/files/{gone}/file").status_code == 404