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
@@ -50,6 +50,7 @@ const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
count: 1,
size_bytes: 0,
created_at: null,
has_link: false,
...extra,
});
+5 -2
View File
@@ -195,7 +195,10 @@ export default function DownloadTray() {
title,
}),
message: t(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
action: { label: t("tray.done.copyLink"), onClick: () => void quickShareLink(qc, job, t) },
action: {
label: t("tray.done.copyLink"),
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
},
secondaryAction: {
label: t("tray.done.open"),
onClick: () => openFile(job.id, job.folder_id),
@@ -295,7 +298,7 @@ export default function DownloadTray() {
id: "copyLink",
label: t("tray.done.copyLink"),
icon: <Link className="w-4 h-4" />,
onSelect: () => void quickShareLink(qc, job, t),
onSelect: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
},
]);
const menuItems = menu.target
@@ -151,6 +151,7 @@ const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
count: 1,
size_bytes: 0,
created_at: null,
has_link: false,
...extra,
});
const YT = folder(1, { source_key: "youtube" });
@@ -853,12 +854,14 @@ describe("Files · the grid", () => {
describe("Files · the open folder's menu", () => {
const folderMenu = () => screen.getByRole("button", { name: "files.folder.menu" });
it("an own folder is renamed, kept for a while, or deleted", async () => {
it("an own folder is shared by link, renamed, kept for a while, or deleted", async () => {
mocks.files = [];
mocks.folders = [YT, OWN];
mount({ kind: "folder", id: 2 });
await openMenu(() => fireEvent.click(folderMenu()));
expect(menuRows()).toEqual([
"files.actions.copyLink",
"files.folder.shareLink",
"files.folder.rename",
"files.folder.retention",
"files.folder.delete",
+1
View File
@@ -751,6 +751,7 @@ export default function Files() {
ariaLabel={t("files.folder.menu")}
items={folderMenu.target ? folderActions.items(folderMenu.target) : []}
/>
{folderActions.dialog}
<Suspense fallback={null}>
{upload && (
<UploadDialog
@@ -62,6 +62,7 @@ const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
count: 1,
size_bytes: 0,
created_at: null,
has_link: false,
...extra,
});
const YT = folder(1, { source_key: "youtube" });
@@ -124,6 +125,8 @@ describe("FilesRail · folders", () => {
});
await waitFor(() =>
expect(menuRows()).toEqual([
"files.actions.copyLink",
"files.folder.shareLink",
"files.folder.rename",
"files.folder.retention",
"files.folder.delete",
@@ -196,3 +199,62 @@ describe("FilesRail · what it says when a part is empty or missing", () => {
expect(await screen.findByText("files.usage.failed")).toBeTruthy();
});
});
describe("FilesRail · a folder shared by link", () => {
it("marks the folder a live link opens, and only that one", async () => {
const work = folder(5, { kind: "own", name: "Munka", retention_days: null });
await mount([YT, { ...OWN, has_link: true }, work]);
expect(entry("Receptek").textContent).toContain("files.folder.shared");
expect(entry("Munka").textContent).not.toContain("files.folder.shared");
expect(entry("files.source.youtube").textContent).not.toContain("files.folder.shared");
});
it("copies the folder's link in one click, making it the first time", async () => {
await mount();
const list = vi.spyOn(api, "folderLinks").mockResolvedValue([]);
const make = vi
.spyOn(api, "createFolderLink")
.mockResolvedValue({ id: 1, url: "/watch/f" } as never);
act(() => {
fireEvent.contextMenu(entry("Receptek"));
});
await waitFor(() => expect(menuRows()).toContain("files.actions.copyLink"));
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "files.actions.copyLink")!,
);
await waitFor(() => expect(make).toHaveBeenCalledWith(2, {}));
expect(list).toHaveBeenCalledWith(2);
});
it("opens the folder's Share dialog", async () => {
await mount();
vi.spyOn(api, "folderLinks").mockResolvedValue([
{
id: 1,
token: "f",
url: "/watch/f",
allow_download: true,
has_password: false,
expires_at: null,
view_count: 0,
created_at: null,
},
]);
act(() => {
fireEvent.contextMenu(entry("Receptek"));
});
await waitFor(() => expect(menuRows()).toContain("files.folder.shareLink"));
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "files.folder.shareLink")!,
);
// The dialog is its own chunk, fetched on first use: slower than a re-render under a full run.
expect(
await screen.findByText("downloads.share.folderLinkHint", {}, { timeout: 5000 }),
).toBeTruthy();
expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f`)).toBeTruthy();
});
});
+13 -1
View File
@@ -3,7 +3,7 @@ import { Meter } from "./ui/Meter";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { FolderOpen, FolderPlus, HardDrive, Users } from "lucide-react";
import { FolderOpen, FolderPlus, HardDrive, Link2, Users } from "lucide-react";
import { api, type FileFolder } from "../lib/api";
import { compareText } from "../lib/collate";
import { formatBytes } from "../lib/format";
@@ -36,16 +36,20 @@ function Entry({
view,
trigger,
folderId,
shared = false,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
count?: number;
/** A live public link opens the folder: what is moved in is shown to whoever holds the link. */
shared?: boolean;
view: FilesView;
/** The folder's menu triggers (right click, long press, Shift+F10). */
trigger?: Record<string, unknown>;
/** A folder's entry is found by it, to hand the keyboard back after a rename. */
folderId?: number;
}) {
const { t } = useTranslation();
const { view: current } = useFiles();
const { setView } = useFilesActions();
const active = sameView(view, current);
@@ -64,6 +68,12 @@ function Entry({
>
<Icon className={clsx("size-4 shrink-0", !active && "text-muted")} />
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
{shared && (
<span title={t("files.folder.shared")} className="shrink-0">
<Link2 aria-hidden className="size-3.5 text-accent" />
<span className="sr-only">{t("files.folder.shared")}</span>
</span>
)}
{count !== undefined && <span className="text-xs tabular-nums text-muted">{count}</span>}
</Pressable>
);
@@ -176,6 +186,7 @@ export default function FilesRail({
icon={folderIcon(f)}
label={folderLabel(f, t)}
count={f.count}
shared={f.has_link}
view={{ kind: "folder", id: f.id }}
trigger={menu.triggerProps(f)}
folderId={f.id}
@@ -267,6 +278,7 @@ export default function FilesRail({
ariaLabel={t("files.folder.menu")}
items={menu.target ? folderActions.items(menu.target) : []}
/>
{folderActions.dialog}
</FilterRail>
);
}
@@ -5,7 +5,7 @@
// colour class outranks them (clsx does not merge Tailwind), so a password-protected link looked
// unprotected and Revoke hovered neutral. They live on the ICON now; this pins them there.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
@@ -15,12 +15,30 @@ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
vi.mock("./ui/Modal", () => ({ default: ({ children }: { children: ReactNode }) => children }));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => false }));
const h = vi.hoisted(() => ({ links: null as unknown[] | null }));
vi.mock("./ui/Modal", () => ({
default: ({ title, children }: { title: string; children: ReactNode }) => (
<div>
<h2>{title}</h2>
{children}
</div>
),
}));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => h.confirm }));
const h = vi.hoisted(() => ({
confirm: false,
revokeDownloadLink: vi.fn(),
links: null as unknown[] | null,
folderLinks: vi.fn(),
createFolderLink: vi.fn(),
createDownloadLink: vi.fn(),
}));
vi.mock("../lib/api", () => ({
api: {
shareRecipients: async () => [],
folderLinks: h.folderLinks,
createFolderLink: h.createFolderLink,
createDownloadLink: h.createDownloadLink,
revokeDownloadLink: h.revokeDownloadLink,
downloadLinks: async () =>
h.links ?? [
{
@@ -53,6 +71,7 @@ import type { DownloadJob } from "../lib/api";
afterEach(cleanup);
beforeEach(() => {
h.links = null;
h.confirm = false;
});
const iconClass = (button: HTMLElement) => button.querySelector("svg")?.getAttribute("class") ?? "";
@@ -106,3 +125,86 @@ describe("ShareDialog · a link whose address cannot be shown again", () => {
expect(screen.getAllByRole("button", { name: "downloads.share.revoke" })).toHaveLength(2);
});
});
describe("ShareDialog · a folder", () => {
const link = (id: number) => ({
id,
token: `f${id}`,
url: `/watch/f${id}`,
allow_download: true,
has_password: false,
expires_at: null,
view_count: 0,
created_at: null,
});
it("shares by link only, says what a folder link shows, and makes it for the folder", async () => {
h.folderLinks.mockReset().mockResolvedValue([link(4)]);
h.createFolderLink.mockReset().mockResolvedValue(link(5));
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
</QueryClientProvider>,
);
expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f4`)).toBeTruthy();
expect(h.folderLinks).toHaveBeenCalledWith(7);
expect(screen.getByRole("heading").textContent).toBe("downloads.share.folderTitle");
expect(screen.getByText("downloads.share.folderLinkHint")).toBeTruthy();
// No sharing with a user: "Shared with me" lists files, not folders.
expect(screen.queryByText("downloads.share.userSection")).toBeNull();
// A stream-only link, then the form is back at its default for the next one: downloadable.
fireEvent.click(screen.getByRole("button", { name: "downloads.share.newLink" }));
// The form's own checkbox, under the link rows' (each row has its own).
const allow = () => {
const boxes = screen.getAllByRole("checkbox", { name: "downloads.share.allowDownload" });
return boxes[boxes.length - 1]!;
};
fireEvent.click(allow());
expect((allow() as HTMLInputElement).checked).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "downloads.share.createLink" }));
await waitFor(() =>
expect(h.createFolderLink).toHaveBeenCalledWith(
7,
expect.objectContaining({
allow_download: false,
}),
),
);
expect(h.createDownloadLink).not.toHaveBeenCalled();
fireEvent.click(await screen.findByRole("button", { name: "downloads.share.newLink" }));
expect((allow() as HTMLInputElement).checked).toBe(true);
});
it("makes a link on opening with none, but never right after the last one was revoked", async () => {
h.folderLinks.mockReset().mockResolvedValue([]);
h.createFolderLink.mockReset().mockResolvedValue(link(6));
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(
<QueryClientProvider client={qc}>
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
</QueryClientProvider>,
);
await waitFor(() => expect(h.createFolderLink).toHaveBeenCalledTimes(1));
view.unmount();
h.confirm = true;
h.folderLinks
.mockReset()
.mockResolvedValueOnce([link(4)])
.mockResolvedValue([]);
h.createFolderLink.mockReset();
h.revokeDownloadLink.mockReset().mockResolvedValue({ ok: true, deleted: 4 });
const qc2 = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc2}>
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
</QueryClientProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: "downloads.share.revoke" }));
await waitFor(() => expect(h.revokeDownloadLink).toHaveBeenCalledWith(4));
await screen.findByText("downloads.share.noLinks");
expect(h.createFolderLink).not.toHaveBeenCalled();
});
});
+39 -15
View File
@@ -10,6 +10,7 @@ import { notifyActionError } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard";
import { formatDate } from "../lib/format";
import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../lib/api";
import { fetchLinks, invalidateLinks, linksKey, makeLink, type LinkTarget } from "../lib/downloads";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Input, InnerInput } from "./ui/Input";
@@ -237,12 +238,12 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
);
}
function LinkShare({ job }: { job: DownloadJob }) {
function LinkShare({ target }: { target: LinkTarget }) {
const { t } = useTranslation();
const qc = useQueryClient();
const links = useQuery({
queryKey: qk.downloadLinks(job.id),
queryFn: () => api.downloadLinks(job.id),
queryKey: linksKey(target),
queryFn: () => fetchLinks(target),
});
const [creating, setCreating] = useState(false);
// Downloadable by default — matches the one-click Share (which relies on the backend
@@ -250,11 +251,11 @@ function LinkShare({ job }: { job: DownloadJob }) {
const [allowDownload, setAllowDownload] = useState(true);
const [expiryDays, setExpiryDays] = useState<number | null>(null);
const [password, setPassword] = useState("");
const invalidate = () => void qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
const invalidate = () => invalidateLinks(qc, target);
const create = useMutation({
mutationFn: () =>
api.createDownloadLink(job.id, {
makeLink(target, {
allow_download: allowDownload,
expires_days: expiryDays,
password: password.trim() || undefined,
@@ -262,7 +263,8 @@ function LinkShare({ job }: { job: DownloadJob }) {
onSuccess: () => {
invalidate();
setCreating(false);
setAllowDownload(false);
// Back to the form's own default for the next link: downloadable.
setAllowDownload(true);
setExpiryDays(null);
setPassword("");
},
@@ -276,21 +278,26 @@ function LinkShare({ job }: { job: DownloadJob }) {
const autoTried = useRef(false);
// The mutation OBJECT is rebuilt every render; `mutate` is not (react-query memoises it), so the
// two halves the effect actually uses can be named while `links.data` stays the only real trigger.
// The autoTried ref is what makes it fire exactly once.
const { mutate: createLink, isPending: createPending } = create;
// The autoTried ref is what makes it fire exactly once — and only on the list the dialog OPENED
// with: revoking the last link empties the list too, and minting a fresh public link right after
// the owner revoked one is the opposite of what they asked for.
const { mutate: createLink } = create;
useEffect(() => {
if (links.data && links.data.length === 0 && !autoTried.current && !createPending) {
if (!links.data || autoTried.current) return;
autoTried.current = true;
createLink();
}
}, [links.data, createLink, createPending]);
if (links.data.length === 0) createLink();
}, [links.data, createLink]);
return (
<div>
<div className="flex items-center gap-2 text-sm font-medium mb-1">
<Link2 className="w-4 h-4 text-muted" /> {t("downloads.share.linkSection")}
</div>
<p className="text-xs text-muted mb-2">{t("downloads.share.linkHint")}</p>
<p className="text-xs text-muted mb-2">
{t(
target.kind === "folder" ? "downloads.share.folderLinkHint" : "downloads.share.linkHint",
)}
</p>
<div className="space-y-2">
{(links.data ?? []).map((l) => (
@@ -359,14 +366,31 @@ function LinkShare({ job }: { job: DownloadJob }) {
);
}
export default function ShareDialog({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
// A file is shared with a registered user and by link; a folder by link only — "Shared with me" lists
// files, and a folder link already shows its files to whoever has it.
type ShareDialogProps = { onClose: () => void } & (
| { job: DownloadJob; folder?: never }
| { folder: { id: number; name: string }; job?: never }
);
export default function ShareDialog({ job, folder, onClose }: ShareDialogProps) {
const { t } = useTranslation();
if (folder)
return (
<Modal
initialFocus="close"
title={t("downloads.share.folderTitle", { name: folder.name })}
onClose={onClose}
>
<LinkShare target={{ kind: "folder", id: folder.id }} />
</Modal>
);
return (
<Modal initialFocus="close" title={t("downloads.share.title")} onClose={onClose}>
<div className="space-y-5">
<UserShare job={job} />
<div className="border-t border-border" />
<LinkShare job={job} />
<LinkShare target={{ kind: "file", id: job.id }} />
</div>
</Modal>
);
@@ -324,3 +324,106 @@ describe("WatchPage resume", () => {
expect(loaded(other.container.querySelector("video")!).currentTime).toBe(0);
});
});
// A folder link opens the folder's list: a video or a song plays in the same player a file link has,
// above the list; a document only downloads; Download follows the link's own setting.
describe("WatchPage folder", () => {
const file = (id: number, extra: Record<string, unknown> = {}) => ({
id,
title: `Clip ${id}`,
audio: false,
media_kind: "video",
size_bytes: 1000,
duration_s: 71,
file_url: `/api/public/watch/tok-folder/files/${id}/file`,
poster_url: null,
...extra,
});
const folder = (extra: Record<string, unknown> = {}) => ({
needs_password: false,
kind: "folder",
name: "Friends",
allow_download: true,
files: [file(1), file(2), file(3, { title: "Plan.pdf", media_kind: "file", duration_s: null })],
...extra,
});
const scrolled = vi.fn();
Element.prototype.scrollIntoView = scrolled;
it("lists the folder's files and plays the one picked above the list", async () => {
window.history.replaceState({}, "", "/watch/tok-folder");
mockMeta(folder());
const page = render(<WatchPage />);
expect((await page.findByRole("heading", { level: 1 })).textContent).toBe("Friends");
expect(page.getByText(/^3 files/)).toBeTruthy();
expect(page.container.querySelector("video")).toBeNull();
fireEvent.click(page.getByRole("button", { name: "Play: Clip 2" }));
const video = page.container.querySelector("video")!;
expect(video.getAttribute("src")).toBe("/api/public/watch/tok-folder/files/2/file");
expect(video.autoplay).toBe(true);
expect(page.getByRole("button", { name: "Play: Clip 2" }).getAttribute("aria-current")).toBe(
"true",
);
// The file's title sits under the folder's, one level down.
expect(page.getByRole("heading", { level: 2 }).textContent).toBe("Clip 2");
expect(scrolled).toHaveBeenCalled();
fireEvent.click(page.getByRole("button", { name: "Play: Clip 1" }));
expect(page.container.querySelector("video")!.getAttribute("src")).toBe(
"/api/public/watch/tok-folder/files/1/file",
);
});
it("offers a document for download only, and Download only where the link allows it", async () => {
window.history.replaceState({}, "", "/watch/tok-folder");
mockMeta(folder());
const page = render(<WatchPage />);
await page.findByText("Plan.pdf");
expect(page.queryByRole("button", { name: "Play: Plan.pdf" })).toBeNull();
const pdf = page.getByRole("link", { name: "Download: Plan.pdf" });
expect(pdf.getAttribute("href")).toBe("/api/public/watch/tok-folder/files/3/file");
cleanup();
mockMeta(folder({ allow_download: false }));
const streamOnly = render(<WatchPage />);
await streamOnly.findByText("Plan.pdf");
expect(streamOnly.queryAllByRole("link", { name: /^Download/ })).toEqual([]);
});
it("each file keeps its own place in a folder", async () => {
window.history.replaceState({}, "", "/watch/tok-folder-resume");
mockMeta(folder());
const page = render(<WatchPage />);
fireEvent.click(await page.findByRole("button", { name: "Play: Clip 1" }));
const video = page.container.querySelector("video")!;
let t = 0;
Object.defineProperties(video, {
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
duration: { get: () => 600, configurable: true },
readyState: { get: () => 1, configurable: true },
});
video.dispatchEvent(new Event("loadedmetadata"));
video.dispatchEvent(new Event("playing"));
video.currentTime = 250;
video.dispatchEvent(new Event("pause"));
const points = JSON.stringify(localStorage);
expect(points).toContain("tok-folder-resume/1");
expect(points).not.toContain('"tok-folder-resume"');
});
it("says when the folder is empty", async () => {
window.history.replaceState({}, "", "/watch/tok-folder");
mockMeta(folder({ files: [] }));
const page = render(<WatchPage />);
expect(await page.findByText("This folder is empty.")).toBeTruthy();
expect(page.getByText("0 files")).toBeTruthy();
});
it("a password folder link asks for the folder's password", async () => {
window.history.replaceState({}, "", "/watch/tok-folder-locked");
mockMeta({ needs_password: true, kind: "folder" });
const page = render(<WatchPage />);
expect(await page.findByText("This folder is password-protected")).toBeTruthy();
});
});
+218 -56
View File
@@ -1,8 +1,22 @@
import { useEffect, useId, useState } from "react";
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
import clsx from "clsx";
import {
Download,
File as FileIcon,
Film,
Link2,
Lock,
Music,
Play,
PlayCircle,
Repeat,
} from "lucide-react";
import { Button } from "./ui/Button";
import { Chip } from "./ui/Chip";
import { formatBytes } from "../lib/format";
import { Pressable } from "./ui/Pressable";
import RemoteImage from "./ui/RemoteImage";
import { MEDIA_BADGE } from "./ui/mediaCardStyles";
import { formatBytes, formatDuration } from "../lib/format";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { useMediaSession } from "../lib/mediaSession";
import { useMediaKeys } from "../lib/mediaKeys";
@@ -11,15 +25,17 @@ import { LS, readJSON, writeJSON } from "../lib/storage";
import { send } from "../lib/http";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
import { LinkButton } from "./ui/LinkButton";
import { IconLink, LinkButton } from "./ui/LinkButton";
import { T, lang } from "./watchStrings";
// Standalone, login-free video page for a shared /watch/<token> link. Its own entry (watchMain.tsx,
// watch.html): no auth, no providers, none of the app's bundle. Uses plain fetch and a tiny
// self-contained i18n dict (`watchStrings`: the public viewer has no app language preference).
// Standalone, login-free page for a shared /watch/<token> link: one file, or a folder's list of them.
// Its own entry (watchMain.tsx, watch.html): no auth, no providers, none of the app's bundle. Uses
// plain fetch and a tiny self-contained i18n dict (`watchStrings`: the public viewer has no app
// language preference).
type Meta = {
needs_password: boolean;
// One shared file: a file link's page, or one row of a folder link's.
type FileMeta = {
kind?: "file";
title?: string | null;
uploader?: string | null;
uploader_url?: string | null;
@@ -35,6 +51,21 @@ type Meta = {
poster_url?: string | null;
};
// A folder link's page: what is in the folder now, newest first.
type FolderMeta = {
kind: "folder";
name: string;
allow_download: boolean;
files: (FileMeta & { id: number })[];
};
type Meta = FileMeta | FolderMeta;
// What the link answers before it is opened: a password link says only which kind it is.
type Answer =
| (Meta & { needs_password?: false })
| { needs_password: true; kind?: "file" | "folder" };
// Compact display of a URL: drop the protocol + trailing slash so it fits on one line.
function displayUrl(url: string): string {
try {
@@ -60,37 +91,12 @@ export default function WatchPage() {
"loading" | "ready" | "password" | "gone" | "preparing" | "error"
>(token ? "loading" : "gone");
const [meta, setMeta] = useState<Meta | null>(null);
// A password link's kind, for its title, until it is unlocked.
const [locked, setLocked] = useState<"file" | "folder">("file");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const errorId = useId();
const [unlocking, setUnlocking] = useState(false);
// Whichever element renders (video or audio-only), as state: the OS's media controls, the keys and
// the resume bind to it once it exists.
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
useMediaSession(
status === "ready" ? media : null,
meta && {
title: meta.title || "Video",
artist: meta.uploader || "Siftlode",
artwork: meta.poster_url,
},
);
useMediaKeys(media);
// It opens where this browser stopped in this link (the viewer has no account to keep it in).
// Read once per link, at render: the point a later checkpoint writes must not move the start.
const [start] = useState(() => (token ? linkPoint(token) : 0));
useResume(media, token || null, start, (position, duration) =>
saveLinkPoint(token, position, duration),
);
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
// (this page has no account); an unreadable storage just means the default.
const [repeat, setRepeat] = useState(() => readJSON<boolean>(LS.watchRepeat, true) !== false);
const toggleRepeat = () => {
const next = !repeat;
setRepeat(next);
writeJSON(LS.watchRepeat, next);
};
// Bumped by Retry, which asks again.
const [attempt, setAttempt] = useState(0);
@@ -101,18 +107,20 @@ export default function WatchPage() {
useEffect(() => {
if (!token) return; // the seed above already says "gone"
send(`/api/public/watch/${encodeURIComponent(token)}`)
.then((r): Promise<Meta | "preparing" | "gone"> => {
.then((r): Promise<Answer | "preparing" | "gone"> => {
// Only a 404 says the link is gone (unknown, expired, revoked, or its file removed) — or that
// its video is being made again (`notFoundState`). Anything else — a 5xx while the server
// restarts, a dropped connection — is a failure worth retrying, not a dead link.
if (r.status === 404) return notFoundState(r);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<Meta>;
return r.json() as Promise<Answer>;
})
.then((m) => {
if (typeof m === "string") setStatus(m);
else if (m.needs_password) setStatus("password");
else {
else if (m.needs_password) {
setLocked(m.kind ?? "file");
setStatus("password");
} else {
setMeta(m);
setStatus("ready");
}
@@ -231,7 +239,8 @@ export default function WatchPage() {
<div className="flex-1 grid place-items-center">
<div className={`${SURFACE.panel} w-full max-w-sm p-6`}>
<div className="flex items-center gap-2 font-medium mb-4">
<Lock className="w-4 h-4 text-accent" /> {T.passwordTitle}
<Lock className="w-4 h-4 text-accent" />{" "}
{locked === "folder" ? T.passwordTitleFolder : T.passwordTitle}
</div>
<Input
className="w-full"
@@ -263,40 +272,86 @@ export default function WatchPage() {
</div>
)}
{status === "ready" && meta && meta.media_kind === "file" && (
{status === "ready" && meta?.kind === "folder" && (
<FolderView folder={meta} token={token} />
)}
{status === "ready" && meta && meta.kind !== "folder" && (
<FileView meta={meta} pointKey={token} />
)}
</div>
<div className="text-xs text-muted py-4">{T.brand}</div>
</div>
);
}
/** One shared file: its player (or, for a document, its card), title and actions. `pointKey` is where
* this browser keeps the point it stopped at: the link's token, or the token and the file's id in a
* folder. `nested` is a file shown inside a folder's page, whose heading is the folder's. */
function FileView({
meta,
pointKey,
nested = false,
}: {
meta: FileMeta;
pointKey: string;
nested?: boolean;
}) {
const Heading = nested ? "h2" : "h1";
// Whichever element renders (video or audio-only), as state: the OS's media controls, the keys and
// the resume bind to it once it exists.
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
useMediaSession(media, {
title: meta.title || "Video",
artist: meta.uploader || "Siftlode",
artwork: meta.poster_url,
});
useMediaKeys(media);
// It opens where this browser stopped in this file (the viewer has no account to keep it in).
// Read once per file, at render: the point a later checkpoint writes must not move the start.
const [start] = useState(() => linkPoint(pointKey));
useResume(media, pointKey, start, (position, duration) =>
saveLinkPoint(pointKey, position, duration),
);
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
// (this page has no account); an unreadable storage just means the default.
const [repeat, setRepeat] = useState(() => readJSON<boolean>(LS.watchRepeat, true) !== false);
const toggleRepeat = () => {
const next = !repeat;
setRepeat(next);
writeJSON(LS.watchRepeat, next);
};
if (meta.media_kind === "file")
/* A generic uploaded file has no player — just an info card (name/type/size) and, if the
link allows it, a Download. The backend serves the file force-download regardless, so
gating the button on allow_download only mirrors the owner's ACL, matching the media
branch's download affordance below. It is the page's one action, so it is the filled
primary, as the unlock card's Watch is. */
return (
<div className="flex-1 grid place-items-center">
<div className={`${SURFACE.panel} w-full max-w-md p-6 text-center`}>
<div className="mx-auto w-16 h-16 rounded-xl bg-surface grid place-items-center mb-4">
<FileIcon className="w-8 h-8 text-accent" />
</div>
<h1 className="text-lg font-semibold leading-snug break-words">
<Heading className="text-lg font-semibold leading-snug break-words">
{meta.title || T.file}
</h1>
</Heading>
<div className="text-sm text-muted mt-1">
{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes, lang)]
.filter(Boolean)
.join(" · ")}
</div>
{meta.allow_download && (
<LinkButton
href={meta.file_url}
variant="primary"
icon={<Download />}
className="mt-5"
>
<LinkButton href={meta.file_url} variant="primary" icon={<Download />} className="mt-5">
{T.download}
</LinkButton>
)}
</div>
</div>
)}
);
{status === "ready" && meta && meta.media_kind !== "file" && (
return (
<div>
{meta.audio ? (
/* Audio-only asset (mp3): WebKit won't play it in a <video>, so render a native
@@ -306,6 +361,7 @@ export default function WatchPage() {
<audio
ref={setMedia}
controls
autoPlay={nested}
loop={repeat}
src={meta.file_url}
className="w-full block"
@@ -323,10 +379,12 @@ export default function WatchPage() {
}}
controls
playsInline
// In a folder the viewer picked it from the list just now: it starts.
autoPlay={nested}
loop={repeat}
src={meta.file_url}
poster={meta.poster_url || undefined}
className="max-h-[80dvh] max-w-full mx-auto block"
className={clsx("max-w-full mx-auto block", nested ? "max-h-[60dvh]" : "max-h-[80dvh]")}
/>
</div>
)}
@@ -334,9 +392,9 @@ export default function WatchPage() {
than squeezing the title into a column a word wide. */}
<div className={`${SURFACE.panel} mt-3 flex flex-wrap items-start gap-x-3 gap-y-2 p-3`}>
<div className="min-w-0 flex-1 basis-60">
<h1 className="text-lg font-semibold leading-snug break-words">
<Heading className="text-lg font-semibold leading-snug break-words">
{meta.title || "Video"}
</h1>
</Heading>
{meta.uploader &&
(meta.uploader_url ? (
<a
@@ -391,9 +449,113 @@ export default function WatchPage() {
</div>
)}
</div>
);
}
/** A file's picture in the folder list: its poster frame, else a glyph of its kind; a video or a
* song carries its length. */
function RowThumb({ file }: { file: FileMeta }) {
const Glyph = file.media_kind === "file" ? FileIcon : file.audio ? Music : Film;
const length = file.media_kind !== "file" && file.duration_s ? file.duration_s : null;
// Above the length badge when there is one, so the badge never covers it.
const glyph = (
<Glyph
aria-hidden
className={clsx(
"absolute inset-x-0 mx-auto text-muted",
length ? "top-1 size-4" : "inset-y-0 my-auto size-5",
)}
/>
);
return (
<div className="relative h-10 w-16 shrink-0 overflow-hidden rounded-lg bg-surface">
<RemoteImage
src={file.poster_url}
alt=""
loading="lazy"
className="h-full w-full object-cover"
fallback={glyph}
/>
{length ? (
<span className={clsx(MEDIA_BADGE, "bottom-0.5 right-0.5")}>{formatDuration(length)}</span>
) : null}
</div>
);
}
/** A folder link's page: the folder's name, its files, and the one playing above them. Play opens the
* same player a file link has; a document only downloads. */
function FolderView({ folder, token }: { folder: FolderMeta; token: string }) {
const [playing, setPlaying] = useState<number | null>(null);
const current = folder.files.find((f) => f.id === playing);
const total = folder.files.reduce((n, f) => n + (f.size_bytes ?? 0), 0);
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-xl font-bold leading-snug break-words">{folder.name}</h1>
<div className="text-sm text-muted">
{[T.files(folder.files.length), folder.files.length > 0 && formatBytes(total, lang)]
.filter(Boolean)
.join(" · ")}
</div>
</div>
{current && (
// Keyed by the file, so a new pick is a new player (its own resume point, its own media
// session) and brings the player into view — on a phone it is above the row just tapped.
<div key={current.id} ref={(el) => el?.scrollIntoView({ block: "start" })}>
<FileView meta={current} pointKey={`${token}/${current.id}`} nested />
</div>
)}
{folder.files.length === 0 ? (
<p className="text-sm text-muted">{T.folderEmpty}</p>
) : (
<ul className="flex flex-col">
{folder.files.map((f) => {
const title = f.title || T.file;
const body = (
<>
<RowThumb file={f} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">{title}</span>
<span className="block text-xs text-muted">
{formatBytes(f.size_bytes, lang)}
</span>
</span>
</>
);
return (
<li
key={f.id}
className={clsx(
"flex items-center gap-1 rounded-lg",
f.id === playing && "bg-accent/10",
)}
>
{f.media_kind === "file" ? (
<div className="flex min-w-0 flex-1 items-center gap-3 px-1.5 py-1.5">{body}</div>
) : (
<Pressable
layer
selectable
aria-current={f.id === playing ? "true" : undefined}
aria-label={`${T.play}: ${title}`}
onClick={() => setPlaying(f.id)}
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-1.5 py-1.5"
>
{body}
<Play aria-hidden className="size-4 shrink-0 text-muted" />
</Pressable>
)}
{folder.allow_download && (
<IconLink href={f.file_url} label={`${T.download}: ${title}`}>
<Download />
</IconLink>
)}
</li>
);
})}
</ul>
)}
</div>
<div className="text-xs text-muted py-4">{T.brand}</div>
</div>
);
}
+1 -1
View File
@@ -108,7 +108,7 @@ export function useFileActions({
const quickShare = async (file: LibraryFile) => {
if (sharingId !== null) return;
setSharingId(file.id);
await quickShareLink(qc, file, t).finally(() => setSharingId(null));
await quickShareLink(qc, { kind: "file", id: file.id }, t).finally(() => setSharingId(null));
};
const icon = (I: typeof PlayCircle) => <I className="w-4 h-4" />;
+46 -6
View File
@@ -1,18 +1,22 @@
import { lazy, Suspense, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Clock, Pencil, Trash2 } from "lucide-react";
import { Clock, Link, Pencil, Share2, Trash2 } from "lucide-react";
import { api, type FileFolder, type FolderPatch } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads } from "../lib/downloads";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { useConfirm } from "./confirmContext";
import { folderLabel } from "./fileFolders";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { menuGroups } from "./ui/menuGroups";
// A library folder's ONE menu — the rail entry's right click and the Files page's "…" beside an open
// folder's name. An own folder is renamed (inline, where it is named), kept for a while and deleted; a
// source folder is named by its origin and cannot go, so only how long it keeps its files is its
// user's to set (the server holds the same rules: `routes/files.py`).
// folder's name. An own folder is shared by link (its link opens whatever is in it), renamed (inline,
// where it is named), kept for a while and deleted; a source folder is named by its origin and cannot
// go, so only how long it keeps its files is its user's to set (the server holds the same rules:
// `routes/files.py`, `routes/downloads.py`).
const ShareDialog = lazy(() => import("./ShareDialog"));
/** The choices the Retention submenu offers (days; null = until the user deletes the file). */
const RETENTION_CHOICES: readonly (number | null)[] = [7, 30, 90, null];
@@ -25,6 +29,9 @@ export function useFolderActions({ onRename }: { onRename: (folder: FileFolder)
const qc = useQueryClient();
const { t } = useTranslation();
const confirm = useConfirm();
// The own folder whose Share dialog is open (the hook's caller renders `dialog`).
const [sharing, setSharing] = useState<FileFolder | null>(null);
const [copying, setCopying] = useState(false);
// Every write lands in the listings too: a retention change moves its files' expiry, a delete moves
// the files themselves.
@@ -83,10 +90,34 @@ export function useFolderActions({ onRename }: { onRename: (folder: FileFolder)
if (ok) remove.mutate(folder.id);
};
// The same two a file row has, in its order: one click copies the folder's link (made the first
// time), the dialog is for a password, an expiry or revoking it.
const share = (folder: FileFolder): ActionMenuItem[] => [
{
id: "copy-link",
label: t("files.actions.copyLink"),
icon: icon(Link),
disabled: copying,
onSelect: () => {
setCopying(true);
void quickShareLink(qc, { kind: "folder", id: folder.id }, t).finally(() =>
setCopying(false),
);
},
},
{
id: "share-link",
label: t("files.folder.shareLink"),
icon: icon(Share2),
onSelect: () => setSharing(folder),
},
];
/** The folder's menu rows. */
const items = (folder: FileFolder): ActionMenuItem[] =>
folder.kind === "own"
? menuGroups(
share(folder),
[
{
id: "rename",
@@ -126,5 +157,14 @@ export function useFolderActions({ onRename }: { onRename: (folder: FileFolder)
}
};
return { items, rename, add };
const dialog = sharing && (
<Suspense fallback={null}>
<ShareDialog
folder={{ id: sharing.id, name: folderLabel(sharing, t) }}
onClose={() => setSharing(null)}
/>
</Suspense>
);
return { items, rename, add, dialog };
}
+12
View File
@@ -26,6 +26,10 @@ const STR = {
crashTitle: "Something went wrong.",
crashHint: "Reload the page to try again.",
reload: "Reload",
passwordTitleFolder: "This folder is password-protected",
folderEmpty: "This folder is empty.",
play: "Play",
files: (n: number) => (n === 1 ? "1 file" : `${n} files`),
},
hu: {
loading: "Betöltés…",
@@ -50,6 +54,10 @@ const STR = {
crashTitle: "Valami hiba történt.",
crashHint: "Töltsd újra az oldalt, és próbáld újra.",
reload: "Újratöltés",
passwordTitleFolder: "Ez a mappa jelszóval védett",
folderEmpty: "Ez a mappa üres.",
play: "Lejátszás",
files: (n: number) => `${n} fájl`,
},
de: {
loading: "Wird geladen…",
@@ -74,6 +82,10 @@ const STR = {
crashTitle: "Etwas ist schiefgelaufen.",
crashHint: "Lade die Seite neu und versuche es erneut.",
reload: "Neu laden",
passwordTitleFolder: "Dieser Ordner ist passwortgeschützt",
folderEmpty: "Dieser Ordner ist leer.",
play: "Abspielen",
files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`),
},
} as const;
+9
View File
@@ -6,6 +6,7 @@ import { BatchC } from "./batchC";
import { BatchD } from "./batchD";
import { FilesRound } from "./filesRound";
import { TokensRound } from "./tokensRound";
import { LinksRound } from "./linksRound";
import { Chip } from "../components/ui/Chip";
import { Slider } from "../components/ui/NativeControls";
import { SURFACE } from "../components/ui/surfaces";
@@ -105,6 +106,14 @@ export default function DesignLab() {
Resize the window to tablet size to see them there too.
</p>
</header>
<header id="open-links" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Decided · Folder links</h2>
<p className="text-sm text-muted">
A /watch link that opens a whole folder. Picked: A, A, A — the variants with the accent
edge (built; on the public list a picture-less row keeps its glyph above the length).
</p>
</header>
<LinksRound />
<header id="open-tokens" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Decided · API tokens, a machine&apos;s key to Files</h2>
<p className="text-sm text-muted">
+420
View File
@@ -0,0 +1,420 @@
import clsx from "clsx";
import type { ReactNode } from "react";
import {
Clock,
Copy,
Download,
Eye,
FileText,
Folder,
FolderPlus,
Link2,
Lock,
Pencil,
Play,
Trash2,
Users,
X,
} from "lucide-react";
import { Decision, Sample, Variant } from "./labKit";
import { SURFACE } from "../components/ui/surfaces";
import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles";
import { MEDIA_BADGE } from "../components/ui/mediaCardStyles";
// The folder links round: a /watch link that opens a whole folder, not one file. Static markup on
// purpose (the lab's rule); the folder and its files are the owner's own kind of content.
const ICON = iconButtonClass({ variant: "ghost", size: "sm" });
type File = { name: string; size: string; length?: string; doc?: boolean; hue: string };
const FILES: File[] = [
{ name: "Balaton 2026 — a móló", size: "48,1 MB", length: "2:14", hue: "from-accent/45" },
{ name: "Szülinapi torta, gyertyafújás", size: "19,9 MB", length: "1:11", hue: "from-rating/45" },
{ name: "Útvonal és szállás.pdf", size: "640 KB", doc: true, hue: "from-fg/20" },
{ name: "Esti tábortűz", size: "7,3 MB", length: "0:38", hue: "from-success/40" },
];
function Thumb({ file, className }: { file: File; className: string }) {
return (
<div
className={clsx(
"relative shrink-0 overflow-hidden rounded-lg bg-linear-to-br to-card",
file.hue,
className,
)}
>
{file.doc && <FileText className="absolute inset-0 m-auto size-5 text-muted" />}
{file.length && <span className={clsx(MEDIA_BADGE, "bottom-1 right-1")}>{file.length}</span>}
</div>
);
}
// ── L1: making a folder link ────────────────────────────────────────────────────────────────────
function RailEntry({
label,
count,
shared,
active,
}: {
label: string;
count: number;
shared?: boolean;
active?: boolean;
}) {
return (
<span
className={clsx(
"flex h-8 items-center gap-2 rounded-lg px-2 text-sm",
active ? "bg-accent/12 font-medium text-accent" : "text-muted",
)}
>
<Folder className="size-4 shrink-0" />
<span className="flex-1 truncate">{label}</span>
{shared && <Link2 className="size-3.5 shrink-0 text-accent" aria-label="Linkkel megosztva" />}
<span className="text-xs tabular-nums">{count}</span>
</span>
);
}
function Rail({ marked }: { marked: boolean }) {
return (
<aside className="flex w-52 shrink-0 flex-col gap-0.5">
<span className="flex items-center px-2 text-2xs uppercase tracking-wider text-muted">
<span className="flex-1">Saját mappák</span>
<FolderPlus className="size-3.5" />
</span>
<RailEntry label="Cimboráknak" count={4} shared={marked} active />
<RailEntry label="Receptek" count={1} />
<RailEntry label="Munka" count={7} shared={marked} />
</aside>
);
}
function FolderMenu({ quickCopy }: { quickCopy: boolean }) {
const row = "flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm";
const groups: [typeof Folder, string, boolean?][][] = [
quickCopy
? [
[Link2, "Link másolása"],
[Users, "Megosztás linkkel…"],
]
: [[Users, "Megosztás linkkel…"]],
[
[Clock, "Megőrzés"],
[Pencil, "Átnevezés"],
],
[[Trash2, "Törlés", true]],
];
return (
<div className={clsx(SURFACE.menu, "w-56 shadow-float")}>
{groups.map((g, gi) => (
<div key={gi}>
{gi > 0 && <div className="my-1 h-px bg-border" />}
{g.map(([Icon, label, danger], i) => (
<span
key={label}
className={clsx(row, danger && "text-danger", gi === 0 && i === 0 && "bg-fg/8")}
>
<Icon className={clsx("size-4 shrink-0", danger ? "text-danger" : "text-muted")} />
{label}
</span>
))}
</div>
))}
</div>
);
}
function LinkRow({ views, pw }: { views: number; pw?: boolean }) {
return (
<div className={clsx(SURFACE.card, "flex flex-col gap-2 p-2.5")}>
<div className="flex items-center gap-2">
<Link2 className="size-4 shrink-0 text-muted" />
<span className="flex-1 truncate text-xs text-muted">
https://siftlode.example/watch/Qk7vN2x…
</span>
<span className={ICON}>
<Copy className="size-4" />
</span>
<span className={ICON}>
<Lock className={clsx("size-4", pw && "text-accent")} />
</span>
<span className={ICON}>
<Trash2 className="size-4 text-danger" />
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted">
<Eye className="size-3" /> {views} megtekintés
{pw && <span className="rounded-sm bg-surface px-1.5 py-0.5">jelszóval</span>}
<span className="rounded-sm bg-surface px-1.5 py-0.5">letölthető</span>
</div>
</div>
);
}
function FolderShareDialog() {
return (
<div className={clsx(SURFACE.panel, "flex w-full max-w-md flex-col gap-3 p-4")}>
<div className="flex items-center">
<h4 className="flex-1 font-semibold">Mappa megosztása · Cimboráknak</h4>
<X className="size-4 text-muted" />
</div>
<div className="flex items-center gap-2 text-sm font-medium">
<Link2 className="size-4 text-muted" /> Megosztás linkkel
</div>
<p className="-mt-2 text-xs text-muted">
A link birtokában bárki megnézheti a mappát — fiók nélkül is. Mindig a mappa aktuális
tartalmát látja: az ide később tett fájlokat is, a kivetteket már nem.
</p>
<LinkRow views={3} />
<span className={clsx(buttonClass({ variant: "outline", size: "sm" }), "self-start")}>
<Link2 className="size-4" /> Link létrehozása
</span>
</div>
);
}
// ── L2: the page the link opens ─────────────────────────────────────────────────────────────────
function Brand() {
return <span className="text-xs text-muted">Megosztva a Siftlode-dal</span>;
}
function PageFrame({ phone, children }: { phone?: boolean; children: ReactNode }) {
return (
<div
className={clsx(
"flex flex-col gap-3 rounded-xl bg-bg p-3 ring-1 ring-border",
phone ? "w-[23.5rem] max-w-full" : "w-full",
)}
>
{children}
</div>
);
}
function Header() {
return (
<div className="flex items-end gap-2">
<div className="flex-1">
<h4 className="text-lg font-bold">Cimboráknak</h4>
<span className="text-xs text-muted">4 fájl · 76 MB</span>
</div>
<Brand />
</div>
);
}
function Player({ file }: { file: File }) {
return (
<div className="flex flex-col gap-1.5">
<div
className={clsx(
"relative aspect-video w-full overflow-hidden rounded-xl bg-linear-to-br to-card",
file.hue,
)}
>
<span className="absolute inset-x-3 bottom-2 flex items-center gap-2 text-xs text-fg/80">
<Play className="size-3.5" /> 0:41 / {file.length}
<span className="h-1 flex-1 rounded-full bg-fg/20">
<span className="block h-full w-1/3 rounded-full bg-accent" />
</span>
</span>
</div>
<div className="flex items-center gap-2">
<span className="flex-1 truncate text-sm font-semibold">{file.name}</span>
<span className={buttonClass({ variant: "outline", size: "xs" })}>
<Download className="size-3.5" /> Letöltés
</span>
</div>
</div>
);
}
function ListPage({ phone, playing }: { phone?: boolean; playing?: boolean }) {
return (
<PageFrame phone={phone}>
<Header />
{playing && <Player file={FILES[1]!} />}
<div className="flex flex-col">
{FILES.map((f, i) => (
<div
key={f.name}
className={clsx(
"flex items-center gap-3 rounded-lg px-1.5 py-1.5",
playing && i === 1 && "bg-accent/10",
)}
>
<Thumb file={f} className="h-10 w-16" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{f.name}</div>
<div className="text-xs text-muted">{f.size}</div>
</div>
{!f.doc && (
<span className={ICON}>
<Play className="size-4" />
</span>
)}
<span className={ICON}>
<Download className="size-4" />
</span>
</div>
))}
</div>
</PageFrame>
);
}
function GridPage() {
return (
<PageFrame>
<Header />
<div className="grid grid-cols-2 gap-3">
{FILES.map((f) => (
<div key={f.name} className="flex flex-col gap-1">
<Thumb file={f} className="aspect-video w-full" />
<span className="truncate text-sm">{f.name}</span>
<span className="flex items-center gap-1 text-xs text-muted">
<span className="flex-1">{f.size}</span>
<Download className="size-3.5" />
</span>
</div>
))}
</div>
</PageFrame>
);
}
function DownloadOnlyPage() {
return (
<PageFrame>
<Header />
<div className="flex flex-col divide-y divide-border/60">
{FILES.map((f) => (
<div key={f.name} className="flex items-center gap-3 py-2">
<FileText className="size-4 shrink-0 text-muted" />
<span className="min-w-0 flex-1 truncate text-sm">{f.name}</span>
<span className="text-xs text-muted tabular-nums">{f.size}</span>
<span className={buttonClass({ variant: "outline", size: "xs" })}>
<Download className="size-3.5" /> Letöltés
</span>
</div>
))}
</div>
</PageFrame>
);
}
// ── The round ───────────────────────────────────────────────────────────────────────────────────
export function LinksRound() {
return (
<div className="flex flex-col gap-6">
<Decision
id="open-links-l1"
code="L1"
title="Making a folder link"
question="A folder's right-click menu gets the share. Does it also get the one-click “Link másolása” a file row has, or only the dialog? The dialog is the file's Share dialog with only its link half (sharing with a user stays per file)."
now="Only a file can be shared; a folder menu has Retention, Rename and Delete."
>
<div className="grid gap-4 xl:grid-cols-3">
<Variant
letter="A"
name="Copy link + Share with link…"
recommended
why="The same two entries a file row has, in the same order: one click copies the folder's link (making one the first time), the dialog is for a password, an expiry or revoking it."
>
<Sample label="the folder's right-click menu">
<div className="flex gap-3">
<Rail marked={false} />
<FolderMenu quickCopy />
</div>
</Sample>
</Variant>
<Variant
letter="B"
name="Share with link… only"
why="One entry: every folder link goes through the dialog. One more click for the common case, nothing gained but a shorter menu."
>
<Sample label="the folder's right-click menu">
<FolderMenu quickCopy={false} />
</Sample>
</Variant>
<Variant
letter="A/B"
name="…the dialog either opens"
recommended
why="It says what a folder link means: whoever has it sees the folder as it is NOW, files put in later included."
>
<FolderShareDialog />
</Variant>
</div>
</Decision>
<Decision
id="open-links-l2"
code="L2"
title="The page a folder link opens"
question="Signed out, often on a phone: the folder's name and its files. Each video plays right there, each file downloads (when the link allows it); a document is never opened as a web page, only downloaded."
now="A /watch link opens one file: its player, title and Download."
>
<div className="grid gap-4 xl:grid-cols-3">
<Variant
letter="A"
name="A list; Play opens the player above it"
recommended
why="The watch page's player, over a list you can read on a phone: a long name gets a full line, the playing row stays lit, the next one is one tap away. Mostly videos from a phone — this is the YouTube-playlist shape people know."
>
<Sample label="desktop, a video playing">
<ListPage playing />
</Sample>
<Sample label="phone">
<ListPage phone />
</Sample>
</Variant>
<Variant
letter="B"
name="Cards"
why="Pictures first, like the Files grid. Nicer for a folder of photos-like clips, but names truncate to a line and a document is a grey card."
>
<GridPage />
</Variant>
<Variant
letter="C"
name="A download list only"
why="Like a file-transfer page: name, size, Letöltés. Simplest, but a friend has to download a clip to see it — on a phone, on mobile data."
>
<DownloadOnlyPage />
</Variant>
</div>
</Decision>
<Decision
id="open-links-l3"
code="L3"
title="Seeing which folders are shared"
question="A folder link keeps showing whatever you put in the folder later. Does the rail mark a folder that has a live link, so you know where you drop files in public?"
now="Nothing marks a shared file or folder."
>
<div className="grid gap-4 xl:grid-cols-2">
<Variant
letter="A"
name="A link mark beside the count"
recommended
why="A folder link is the one place where moving a file in publishes it. The mark is quiet (one small icon), and it goes when the last link is revoked or runs out."
>
<Rail marked />
</Variant>
<Variant
letter="B"
name="No mark"
why="A cleaner rail. You learn a folder is shared only from its Share dialog."
>
<Rail marked={false} />
</Variant>
</div>
</Decision>
</div>
);
}
+3 -1
View File
@@ -118,7 +118,9 @@
"newPassword": "New password",
"savePassword": "Save",
"removePassword": "Delete",
"removePasswordConfirm": "This makes the link public — anyone with the URL can watch it without a password."
"removePasswordConfirm": "This makes the link public — anyone with the URL can watch it without a password.",
"folderTitle": "Share folder · {{name}}",
"folderLinkHint": "Anyone with the link can see the folder — no account needed. They see what is in it when they open it: files you put in later too, not the ones you take out."
},
"confirm": {
"cancelTitle": "Cancel download?",
+1
View File
@@ -199,6 +199,7 @@
"share_own": "That's already your download",
"share_password_required": "This link needs a password",
"share_unfinished": "Only a finished download can be shared",
"share_folder_kind": "Only a folder of your own can be shared by link",
"smtp_failed": "The email couldn't be sent — check the SMTP settings",
"smtp_off": "SMTP isn't configured",
"spec_not_object": "The profile's settings are malformed",
+3 -1
View File
@@ -84,7 +84,9 @@
"deleteBodyEmpty": "The folder is empty. Nothing else changes.",
"deleteBody_one": "Its file goes back to the folder it came from, and its retention starts again from today.",
"deleteBody_other": "Its {{count}} files go back to the folders they came from, and their retention starts again from today.",
"deleteAction": "Delete folder"
"deleteAction": "Delete folder",
"shareLink": "Share with a link…",
"shared": "Shared with a link"
},
"layout": {
"label": "View",
+3 -1
View File
@@ -118,7 +118,9 @@
"newPassword": "Új jelszó",
"savePassword": "Mentés",
"removePassword": "Törlés",
"removePasswordConfirm": "Ezzel a link publikus lesz — jelszó nélkül bárki megnézheti, akinek megvan az URL."
"removePasswordConfirm": "Ezzel a link publikus lesz — jelszó nélkül bárki megnézheti, akinek megvan az URL.",
"folderTitle": "Mappa megosztása · {{name}}",
"folderLinkHint": "A link birtokában bárki megnézheti a mappát — fiók nélkül is. Azt látja, ami megnyitáskor benne van: a később betett fájlokat is, a kivetteket már nem."
},
"confirm": {
"cancelTitle": "Megszakítod a letöltést?",
+1
View File
@@ -199,6 +199,7 @@
"share_own": "Ez már a te letöltésed",
"share_password_required": "Ehhez a linkhez jelszó kell",
"share_unfinished": "Csak befejezett letöltés osztható meg",
"share_folder_kind": "Linkkel csak saját mappa osztható meg",
"smtp_failed": "Nem sikerült elküldeni a levelet — ellenőrizd az SMTP-beállításokat",
"smtp_off": "Az SMTP nincs beállítva",
"spec_not_object": "A profil beállításai hibás formátumúak",
+3 -1
View File
@@ -84,7 +84,9 @@
"deleteBodyEmpty": "A mappa üres. Más nem változik.",
"deleteBody_one": "A benne lévő fájl visszakerül abba a mappába, ahonnan jött, és a megőrzési ideje mától újraindul.",
"deleteBody_other": "A benne lévő {{count}} fájl visszakerül abba a mappába, ahonnan jött, és a megőrzési idejük mától újraindul.",
"deleteAction": "Mappa törlése"
"deleteAction": "Mappa törlése",
"shareLink": "Megosztás linkkel…",
"shared": "Linkkel megosztva"
},
"layout": {
"label": "Nézet",
+16 -4
View File
@@ -151,6 +151,13 @@ export interface ShareLink {
created_at: string | null;
}
// What a new link is made with; an omitted `allow_download` takes the server's default (true).
export interface NewShareLink {
allow_download?: boolean;
expires_days?: number | null;
password?: string;
}
// The width of every user-supplied display name on a job, mirroring the backend's DISPLAY_TEXT_MAX
// (backend/app/models.py) so an input can stop the user rather than letting the server truncate in
// silence. The two are held together by `downloads.contract.test.ts` — a number living in two
@@ -258,16 +265,21 @@ export const downloadsApi = {
shareRecipients: (): Promise<ShareRecipient[]> => req("/api/downloads/recipients"),
// Public watch links (share by link).
downloadLinks: (jobId: number): Promise<ShareLink[]> => req(`/api/downloads/${jobId}/links`),
createDownloadLink: (
jobId: number,
body: { allow_download?: boolean; expires_days?: number | null; password?: string },
): Promise<ShareLink> =>
createDownloadLink: (jobId: number, body: NewShareLink): Promise<ShareLink> =>
req(`/api/downloads/${jobId}/links`, { method: "POST", body: JSON.stringify(body) }),
updateDownloadLink: (
linkId: number,
patch: { allow_download?: boolean; expires_days?: number | null; password?: string },
): Promise<ShareLink> =>
req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }),
// A link to one of the user's own folders: it opens whatever is in the folder when it is opened.
folderLinks: (folderId: number): Promise<ShareLink[]> =>
req(`/api/downloads/folders/${folderId}/links`),
createFolderLink: (folderId: number, body: NewShareLink): Promise<ShareLink> =>
req(`/api/downloads/folders/${folderId}/links`, {
method: "POST",
body: JSON.stringify(body),
}),
revokeDownloadLink: (linkId: number): Promise<{ ok: true; deleted: number }> =>
req(`/api/downloads/links/${linkId}`, { method: "DELETE" }),
// The library player's resume checkpoint. A position too early or too close to the end clears it.
+2
View File
@@ -18,6 +18,8 @@ export interface FileFolder {
count: number; // its done files
size_bytes: number;
created_at: string | null;
// A live public link opens it: whatever is moved in is shown to whoever holds the link.
has_link: boolean;
}
// The most files one list answers with (backend `routes/files.py` MAX_LIST, held equal by
+34 -9
View File
@@ -7,14 +7,19 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
// vi.mock is hoisted above these consts, so the mock fns must live in a hoisted block too.
const { downloadLinks, createDownloadLink, copyText, notify } = vi.hoisted(() => ({
const { downloadLinks, createDownloadLink, folderLinks, createFolderLink, copyText, notify } =
vi.hoisted(() => ({
downloadLinks: vi.fn(),
createDownloadLink: vi.fn(),
folderLinks: vi.fn(),
createFolderLink: vi.fn(),
copyText: vi.fn(),
notify: vi.fn(),
}));
}));
vi.mock("./api", () => ({ api: { downloadLinks, createDownloadLink } }));
vi.mock("./api", () => ({
api: { downloadLinks, createDownloadLink, folderLinks, createFolderLink },
}));
vi.mock("./clipboard", () => ({ copyText }));
vi.mock("./notifications", () => ({ notify }));
@@ -38,6 +43,8 @@ function qc() {
beforeEach(() => {
downloadLinks.mockReset();
createDownloadLink.mockReset();
folderLinks.mockReset();
createFolderLink.mockReset();
copyText.mockReset().mockResolvedValue(true);
notify.mockReset();
});
@@ -48,7 +55,7 @@ describe("quickShareLink", () => {
createDownloadLink.mockResolvedValue({ id: 7, url: "/watch/tok7" });
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
const url = await quickShareLink(qc(), { id: 42 }, t);
const url = await quickShareLink(qc(), { kind: "file", id: 42 }, t);
expect(url).toBe(`${origin}/watch/tok7`);
// Minted with an EMPTY body so the backend default (allow_download=true) applies.
@@ -65,7 +72,7 @@ describe("quickShareLink", () => {
it("reuses an existing link instead of minting a duplicate", async () => {
downloadLinks.mockResolvedValue([{ id: 3, url: "/watch/old" }]);
const url = await quickShareLink(qc(), { id: 1 }, t);
const url = await quickShareLink(qc(), { kind: "file", id: 1 }, t);
expect(url).toBe(`${origin}/watch/old`);
expect(createDownloadLink).not.toHaveBeenCalled();
@@ -78,7 +85,7 @@ describe("quickShareLink", () => {
{ id: 3, url: "/watch/older" },
]);
const url = await quickShareLink(qc(), { id: 1 }, t);
const url = await quickShareLink(qc(), { kind: "file", id: 1 }, t);
expect(url).toBe(`${origin}/watch/older`);
expect(createDownloadLink).not.toHaveBeenCalled();
@@ -90,7 +97,7 @@ describe("quickShareLink", () => {
createDownloadLink.mockResolvedValue({ id: 8, url: "/watch/new" });
const client = qc();
const url = await quickShareLink(client, { id: 6 }, t);
const url = await quickShareLink(client, { kind: "file", id: 6 }, t);
expect(url).toBe(`${origin}/watch/new`);
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/new`);
@@ -100,11 +107,29 @@ describe("quickShareLink", () => {
]);
});
it("a folder's link is the folder's own: listed, made and cached under the folder", async () => {
folderLinks.mockResolvedValue([]);
createFolderLink.mockResolvedValue({ id: 9, url: "/watch/fold" });
const client = qc();
client.setQueryData(qk.fileFolders(), []);
const url = await quickShareLink(client, { kind: "folder", id: 5 }, t);
expect(url).toBe(`${origin}/watch/fold`);
expect(folderLinks).toHaveBeenCalledWith(5);
expect(createFolderLink).toHaveBeenCalledWith(5, {});
expect(downloadLinks).not.toHaveBeenCalled();
expect(createDownloadLink).not.toHaveBeenCalled();
expect(client.getQueryData(qk.folderLinks(5))).toEqual([{ id: 9, url: "/watch/fold" }]);
// The rail's mark of a shared folder is asked for again.
expect(client.getQueryState(qk.fileFolders())!.isInvalidated).toBe(true);
});
it("still toasts (warning) with the open action when the clipboard copy fails", async () => {
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]);
copyText.mockResolvedValue(false);
const url = await quickShareLink(qc(), { id: 2 }, t);
const url = await quickShareLink(qc(), { kind: "file", id: 2 }, t);
expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end
const arg = firstToast();
@@ -117,7 +142,7 @@ describe("quickShareLink", () => {
downloadLinks.mockResolvedValue([]);
createDownloadLink.mockRejectedValue(new Error("boom"));
const url = await quickShareLink(qc(), { id: 9 }, t);
const url = await quickShareLink(qc(), { kind: "file", id: 9 }, t);
expect(url).toBeNull();
expect(copyText).not.toHaveBeenCalled();
+34 -11
View File
@@ -1,6 +1,6 @@
import type { QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api } from "./api";
import { api, type NewShareLink } from "./api";
import { copyText } from "./clipboard";
import { notify } from "./notifications";
import { notifyActionError } from "./actionErrors";
@@ -16,28 +16,51 @@ export function invalidateDownloads(qc: QueryClient) {
qc.invalidateQueries({ queryKey: qk.storageUsage() });
}
// What a public link opens: one file, or one of the user's own folders (whatever is in it when the
// link is opened).
export type LinkTarget = { kind: "file"; id: number } | { kind: "folder"; id: number };
export const linksKey = (target: LinkTarget) =>
target.kind === "file" ? qk.downloadLinks(target.id) : qk.folderLinks(target.id);
export const fetchLinks = (target: LinkTarget) =>
target.kind === "file" ? api.downloadLinks(target.id) : api.folderLinks(target.id);
export const makeLink = (target: LinkTarget, body: NewShareLink) =>
target.kind === "file"
? api.createDownloadLink(target.id, body)
: api.createFolderLink(target.id, body);
/** After a link was made, changed or revoked: its list, and — for a folder — the rail's mark of the
* folders a live link opens (an expiry change can end it too). */
export function invalidateLinks(qc: QueryClient, target: LinkTarget) {
void qc.invalidateQueries({ queryKey: linksKey(target) });
if (target.kind === "folder") void qc.invalidateQueries({ queryKey: qk.fileFolders() });
}
// One-click share: the row's Share button turns "share this" into a single action.
// Ensure the job has a public link (minting one on demand — omitting `allow_download` so the backend
// default makes it downloadable), copy it to the clipboard, and toast with an "open in new tab"
// action. A copy failure still toasts (warning) WITH that action, so the link is never a dead end.
// Advanced controls (password, expiry, revoke, share-with-a-user) live in the Share dialog. Returns
// the shared URL, or null if link creation failed — so the caller/tests can react to the outcome.
// Ensure the file or folder has a public link (minting one on demand — omitting `allow_download` so
// the backend default makes it downloadable), copy it to the clipboard, and toast with an "open in
// new tab" action. A copy failure still toasts (warning) WITH that action, so the link is never a dead
// end. Advanced controls (password, expiry, revoke, share-with-a-user) live in the Share dialog.
// Returns the shared URL, or null if link creation failed — so the caller/tests can react to the outcome.
export async function quickShareLink(
qc: QueryClient,
job: { id: number },
target: LinkTarget,
t: (key: string) => string,
): Promise<string | null> {
let path;
try {
const existing = await qc.fetchQuery({
queryKey: qk.downloadLinks(job.id),
queryFn: () => api.downloadLinks(job.id),
queryKey: linksKey(target),
queryFn: () => fetchLinks(target),
});
// The newest link whose address can still be shown; one that cannot is no use to copy.
path = existing.find((l) => l.url)?.url;
if (!path) {
const made = await api.createDownloadLink(job.id, {}); // omit allow_download → backend default (true)
qc.setQueryData(qk.downloadLinks(job.id), [made, ...existing]);
const made = await makeLink(target, {}); // omit allow_download → backend default (true)
qc.setQueryData(linksKey(target), [made, ...existing]);
if (target.kind === "folder") void qc.invalidateQueries({ queryKey: qk.fileFolders() });
path = made.url;
}
if (!path) throw new Error("the new link came back without its address");
+1
View File
@@ -77,6 +77,7 @@ export const qk = {
downloadProfiles: () => ["download-profiles"] as const,
downloadCredentials: () => ["download-credentials"] as const,
downloadLinks: (jobId: number) => ["download-links", jobId] as const,
folderLinks: (folderId: number) => ["folder-links", folderId] as const,
dlQuota: (userId: number) => ["dl-quota", userId] as const,
storyboard: (jobId: number) => ["storyboard", jobId] as const,