From 86712b6b0e4f025fa28885498ec438f5f0bfb8e7 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 15 Oct 2026 02:23:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(files):=20share=20a=20folder=20by=20link?= =?UTF-8?q?=20=E2=80=94=20its=20page=20lists=20what=20is=20in=20it,=20play?= =?UTF-8?q?s=20a=20video=20above=20the=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/alembic/versions/0108_folder_links.py | 40 ++ backend/app/downloads/og.py | 41 +- backend/app/models.py | 25 +- backend/app/routes/downloads.py | 92 +++- backend/app/routes/files.py | 22 +- backend/app/routes/public.py | 216 ++++++-- backend/tests/test_folder_links.py | 278 ++++++++++ .../src/components/ApiTokens.dom.test.tsx | 1 + frontend/src/components/DownloadTray.tsx | 7 +- .../src/components/Files.menu.dom.test.tsx | 5 +- frontend/src/components/Files.tsx | 1 + .../src/components/FilesRail.dom.test.tsx | 62 +++ frontend/src/components/FilesRail.tsx | 14 +- .../src/components/ShareDialog.dom.test.tsx | 110 +++- frontend/src/components/ShareDialog.tsx | 56 +- .../src/components/WatchPage.dom.test.tsx | 103 ++++ frontend/src/components/WatchPage.tsx | 496 ++++++++++++------ frontend/src/components/useFileActions.tsx | 2 +- frontend/src/components/useFolderActions.tsx | 52 +- frontend/src/components/watchStrings.ts | 12 + frontend/src/design-lab/DesignLab.tsx | 9 + frontend/src/design-lab/linksRound.tsx | 420 +++++++++++++++ frontend/src/i18n/locales/en/downloads.json | 4 +- frontend/src/i18n/locales/en/errors.json | 1 + frontend/src/i18n/locales/en/files.json | 4 +- frontend/src/i18n/locales/hu/downloads.json | 4 +- frontend/src/i18n/locales/hu/errors.json | 1 + frontend/src/i18n/locales/hu/files.json | 4 +- frontend/src/lib/api/downloads.ts | 20 +- frontend/src/lib/api/files.ts | 2 + frontend/src/lib/downloads.test.ts | 51 +- frontend/src/lib/downloads.ts | 45 +- frontend/src/lib/queryKeys.ts | 1 + 33 files changed, 1880 insertions(+), 321 deletions(-) create mode 100644 backend/alembic/versions/0108_folder_links.py create mode 100644 backend/tests/test_folder_links.py create mode 100644 frontend/src/design-lab/linksRound.tsx diff --git a/backend/alembic/versions/0108_folder_links.py b/backend/alembic/versions/0108_folder_links.py new file mode 100644 index 00000000..b018470a --- /dev/null +++ b/backend/alembic/versions/0108_folder_links.py @@ -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") diff --git a/backend/app/downloads/og.py b/backend/app/downloads/og.py index 9ccd3148..a9cf65ed 100644 --- a/backend/app/downloads/og.py +++ b/backend/app/downloads/og.py @@ -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".*?", 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( [ "Siftlode", - _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"{html.escape(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 diff --git a/backend/app/models.py b/backend/app/models.py index 7689657f..f7c4567c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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")) diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index 1e11e740..2bebf57e 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -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} diff --git a/backend/app/routes/files.py b/backend/app/routes/files.py index 109b4607..d777b79b 100644 --- a/backend/app/routes/files.py +++ b/backend/app/routes/files.py @@ -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]: diff --git a/backend/app/routes/public.py b/backend/app/routes/public.py index b4a9dc71..1dd34877 100644 --- a/backend/app/routes/public.py +++ b/backend/app/routes/public.py @@ -9,6 +9,7 @@ a short-lived signed grant that the media URL carries (a `