diff --git a/backend/alembic/versions/0108_folder_links.py b/backend/alembic/versions/0108_folder_links.py
new file mode 100644
index 00000000..b018470a
--- /dev/null
+++ b/backend/alembic/versions/0108_folder_links.py
@@ -0,0 +1,40 @@
+"""A share link may open a folder
+
+Revision ID: 0108_folder_links
+Revises: 0107_link_token_at_rest
+Create Date: 2026-10-15
+
+A `/watch` link opened one file. Now it may open one of its owner's own folders instead: the link's
+page lists what is in the folder when it is opened. `folder_id` sits beside `job_id`, exactly one of
+the two set; a folder's links go with the folder.
+
+The downgrade has nowhere to keep a folder link, so it deletes them: they stop opening.
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0108_folder_links"
+down_revision: str | None = "0107_link_token_at_rest"
+branch_labels: str | None = None
+depends_on: str | None = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "download_links",
+ sa.Column("folder_id", sa.Integer(), sa.ForeignKey("file_folders.id", ondelete="CASCADE")),
+ )
+ op.create_index("ix_download_links_folder_id", "download_links", ["folder_id"])
+ op.alter_column("download_links", "job_id", nullable=True)
+ op.create_check_constraint(
+ "ck_download_link_target", "download_links", "(job_id IS NULL) <> (folder_id IS NULL)"
+ )
+
+
+def downgrade() -> None:
+ op.execute("DELETE FROM download_links WHERE folder_id IS NOT NULL")
+ op.drop_constraint("ck_download_link_target", "download_links", type_="check")
+ op.alter_column("download_links", "job_id", nullable=False)
+ op.drop_index("ix_download_links_folder_id", table_name="download_links")
+ op.drop_column("download_links", "folder_id")
diff --git a/backend/app/downloads/og.py b/backend/app/downloads/og.py
index 9ccd3148..a9cf65ed 100644
--- a/backend/app/downloads/og.py
+++ b/backend/app/downloads/og.py
@@ -11,12 +11,14 @@ import html
import re
from pathlib import Path
+from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.config import settings
from app.downloads import links as linksmod
from app.downloads import storage
-from app.models import DownloadJob, MediaAsset
+from app.downloads.status import JobStatus
+from app.models import DownloadJob, DownloadLink, FileFolder, MediaAsset
_OG_BLOCK = re.compile(r".*?", re.DOTALL)
@@ -77,18 +79,21 @@ def _watch_tags(token: str, db: Session) -> str | None:
if link is None or linksmod.is_expired(link):
return None
url = f"{settings.app_base}/watch/{token}"
+ folder = link.folder_id is not None
if link.password_hash:
# Password-gated: don't leak the title/thumbnail in the unfurl.
return "\n ".join(
[
"
Siftlode",
- _tag("og:title", "Password-protected video"),
+ _tag("og:title", "Password-protected " + ("folder" if folder else "video")),
_tag("og:description", "Shared via Siftlode"),
- _tag("og:type", "video.other"),
+ _tag("og:type", "website" if folder else "video.other"),
_tag("og:url", url),
_tag("og:site_name", "Siftlode"),
]
)
+ if folder:
+ return _folder_tags(db, link, url)
job = db.get(DownloadJob, link.job_id)
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
if job is None or asset is None or asset.status != "ready":
@@ -146,6 +151,36 @@ def _watch_tags(token: str, db: Session) -> str | None:
return "\n ".join(tags)
+def _folder_tags(db: Session, link: DownloadLink, url: str) -> str | None:
+ """A folder link's card: the folder's name and how many files it holds — never their names,
+ which the page shows only to whoever opens it."""
+ folder = db.get(FileFolder, link.folder_id)
+ if folder is None or not folder.name:
+ return None
+ count = db.execute(
+ select(func.count(DownloadJob.id)).where(
+ DownloadJob.folder_id == folder.id,
+ DownloadJob.user_id == folder.user_id,
+ DownloadJob.status == JobStatus.DONE,
+ )
+ ).scalar_one()
+ title = folder.name.strip()
+ about = f"{count} file{'' if count == 1 else 's'} · Shared via Siftlode"
+ return "\n ".join(
+ [
+ f"{html.escape(title)}",
+ _tag("og:title", title),
+ _tag("og:description", about),
+ _tag("og:type", "website"),
+ _tag("og:url", url),
+ _tag("og:site_name", "Siftlode"),
+ _tag("twitter:title", title, attr="name"),
+ _tag("twitter:description", about, attr="name"),
+ _tag("twitter:card", "summary", attr="name"),
+ ]
+ )
+
+
def render_watch_html(token: str, page_html: Path, db: Session) -> str | None:
"""Return the watch page (watch.html) with the OG block replaced for this token, or None to
serve the generic page unchanged (invalid/expired/not-ready link, or a template without the
diff --git a/backend/app/models.py b/backend/app/models.py
index 7689657f..f7c4567c 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -1129,22 +1129,31 @@ class DownloadQuota(Base, UpdatedAtMixin):
class DownloadLink(Base, TimestampMixin):
- """A public capability link to one download — the "share by link" surface.
+ """A public capability link to one download, or to one of its owner's own folders — the "share
+ by link" surface.
Anyone holding the unguessable token can watch the file on the login-free `/watch/{token}`
- player page (Google-Drive style). The token itself is never stored as written: a link is found
- by its SHA-256 (`token_hash`), and `token_enc` is the Fernet-encrypted copy its owner copies the
- link from again — absent without a key (`downloads/links.py`). Optional per-link controls: `expires_at`, `allow_download`
- (stream-only vs downloadable), and a `password_hash` (argon2, like a user password). Revoke =
- delete the row. Distinct from `DownloadShare`, which grants a *registered* user access via the
- in-app "Shared with me" list."""
+ player page (Google-Drive style); a folder link opens the folder's list there, with what is in
+ the folder at the time it is opened. The token itself is never stored as written: a link is
+ found by its SHA-256 (`token_hash`), and `token_enc` is the Fernet-encrypted copy its owner
+ copies the link from again — absent without a key (`downloads/links.py`). Optional per-link
+ controls: `expires_at`, `allow_download` (stream-only vs downloadable), and a `password_hash`
+ (argon2, like a user password). Revoke = delete the row. Distinct from `DownloadShare`, which
+ grants a *registered* user access via the in-app "Shared with me" list."""
__tablename__ = "download_links"
+ __table_args__ = (
+ # A link opens exactly one thing: a file or a folder.
+ CheckConstraint("(job_id IS NULL) <> (folder_id IS NULL)", name="ck_download_link_target"),
+ )
id: Mapped[int] = mapped_column(primary_key=True)
- job_id: Mapped[int] = mapped_column(
+ job_id: Mapped[int | None] = mapped_column(
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
)
+ folder_id: Mapped[int | None] = mapped_column(
+ ForeignKey("file_folders.id", ondelete="CASCADE"), index=True
+ )
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
token_enc: Mapped[str | None] = mapped_column(Text) # Fernet ciphertext — never returned/logged
created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py
index 1e11e740..2bebf57e 100644
--- a/backend/app/routes/downloads.py
+++ b/backend/app/routes/downloads.py
@@ -30,10 +30,12 @@ from app.downloads import edit as editmod
from app.downloads import links as linksmod
from app.downloads import quota, service, storage
from app.downloads import urlguard
+from app.downloads.folders import FolderKind
from app.downloads.status import FINISHED, NOT_REFETCHABLE, JobStatus
from app.models import (
DownloadJob,
DownloadLink,
+ FileFolder,
DownloadProfile,
DownloadQuota,
DownloadShare,
@@ -717,8 +719,12 @@ def _own_link(db: Session, user: User, link_id: int) -> DownloadLink:
link = db.get(DownloadLink, link_id)
if link is None:
raise not_found("link")
- job = db.get(DownloadJob, link.job_id)
- if job is None or job.user_id != user.id:
+ target = (
+ db.get(DownloadJob, link.job_id)
+ if link.job_id is not None
+ else db.get(FileFolder, link.folder_id)
+ )
+ if target is None or target.user_id != user.id:
raise not_found("link")
return link
@@ -731,21 +737,17 @@ def _link_expiry(days: int | None) -> datetime | None:
return datetime.now(timezone.utc) + timedelta(days=min(days, 3650))
+def _links_of(db: Session, *where) -> list[dict]:
+ rows = db.execute(select(DownloadLink).where(*where).order_by(DownloadLink.id.desc()))
+ return [linksmod.owner_view(row) for row in rows.scalars()]
+
+
@router.get("/{job_id}/links")
def list_links(
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> list[dict]:
owned_or_404(db, DownloadJob, job_id, user, what="download")
- rows = (
- db.execute(
- select(DownloadLink)
- .where(DownloadLink.job_id == job_id)
- .order_by(DownloadLink.id.desc())
- )
- .scalars()
- .all()
- )
- return [linksmod.owner_view(row) for row in rows]
+ return _links_of(db, DownloadLink.job_id == job_id)
class CreateLinkIn(BaseModel):
@@ -759,20 +761,15 @@ class CreateLinkIn(BaseModel):
expires_days: int | None = None
-@router.post("/{job_id}/links")
-def create_link(
- job_id: int,
- body: CreateLinkIn,
- user: User = Depends(current_user),
- db: Session = Depends(get_db),
-) -> dict:
- job = owned_or_404(db, DownloadJob, job_id, user, what="download")
- if job.status != JobStatus.DONE or job.asset_id is None:
- raise refused(409, "share_unfinished")
+def _new_link(
+ db: Session, user: User, body: CreateLinkIn, **target: int
+) -> tuple[DownloadLink, str]:
+ """Make a link to `target` (`job_id=` or `folder_id=`, already checked to be the user's), and
+ the token it is opened by — which is in hand only now."""
pw = body.password.strip()
token = linksmod.new_token()
link = DownloadLink(
- job_id=job_id,
+ **target,
created_by=user.id,
allow_download=body.allow_download,
password_hash=hash_password(pw) if pw else None,
@@ -787,10 +784,53 @@ def create_link(
audit.AuditAction.DOWNLOAD_LINK_CREATE,
target_type="download_link",
target_id=link.id,
- after={"job_id": job_id, **_link_audit(link)},
+ after={**target, **_link_audit(link)},
)
+ return link, token
+
+
+@router.post("/{job_id}/links")
+def create_link(
+ job_id: int,
+ body: CreateLinkIn,
+ user: User = Depends(current_user),
+ db: Session = Depends(get_db),
+) -> dict:
+ job = owned_or_404(db, DownloadJob, job_id, user, what="download")
+ if job.status != JobStatus.DONE or job.asset_id is None:
+ raise refused(409, "share_unfinished")
+ link, token = _new_link(db, user, body, job_id=job_id)
+ db.commit()
+ return linksmod.owner_view(link, token=token)
+
+
+def _shareable_folder(db: Session, user: User, folder_id: int) -> FileFolder:
+ """One of the user's own folders. A source folder lets its files go on its own clock, and the Hub
+ is the user's channel to a machine of theirs: neither is a shelf to hand out."""
+ folder = owned_or_404(db, FileFolder, folder_id, user, what="folder")
+ if folder.kind != FolderKind.OWN:
+ raise refused(409, "share_folder_kind")
+ return folder
+
+
+@router.get("/folders/{folder_id}/links")
+def list_folder_links(
+ folder_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
+) -> list[dict]:
+ owned_or_404(db, FileFolder, folder_id, user, what="folder")
+ return _links_of(db, DownloadLink.folder_id == folder_id)
+
+
+@router.post("/folders/{folder_id}/links")
+def create_folder_link(
+ folder_id: int,
+ body: CreateLinkIn,
+ user: User = Depends(current_user),
+ db: Session = Depends(get_db),
+) -> dict:
+ _shareable_folder(db, user, folder_id)
+ link, token = _new_link(db, user, body, folder_id=folder_id)
db.commit()
- db.refresh(link)
return linksmod.owner_view(link, token=token)
@@ -856,7 +896,7 @@ def revoke_link(
audit.AuditAction.DOWNLOAD_LINK_REVOKE,
target_type="download_link",
target_id=link_id,
- after={"job_id": link.job_id},
+ after={"job_id": link.job_id} if link.job_id is not None else {"folder_id": link.folder_id},
)
db.commit()
return {"ok": True, "deleted": link_id}
diff --git a/backend/app/routes/files.py b/backend/app/routes/files.py
index 109b4607..d777b79b 100644
--- a/backend/app/routes/files.py
+++ b/backend/app/routes/files.py
@@ -47,6 +47,7 @@ from app.mailtext import lang_for_user
from app.models import (
DISPLAY_TEXT_MAX,
DownloadJob,
+ DownloadLink,
DownloadShare,
FileFolder,
LibraryState,
@@ -352,7 +353,7 @@ def readable_job(db: Session, caller: FilesCaller, job_id: int) -> DownloadJob:
# --- folders -------------------------------------------------------------------------------
-def _serialize_folder(folder: FileFolder, count: int, size: int) -> dict:
+def _serialize_folder(folder: FileFolder, count: int, size: int, linked: bool = False) -> dict:
return {
"id": folder.id,
"kind": folder.kind,
@@ -363,9 +364,23 @@ def _serialize_folder(folder: FileFolder, count: int, size: int) -> dict:
"count": count,
"size_bytes": size,
"created_at": iso(folder.created_at),
+ # A live public link opens it: whatever is moved in is shown to whoever holds the link.
+ "has_link": linked,
}
+def _linked(db: Session, folder_ids: list[int]) -> set[int]:
+ """Which of these folders a live public link opens (one not past its expiry)."""
+ now = datetime.now(timezone.utc)
+ rows = db.execute(
+ select(DownloadLink.folder_id).where(
+ DownloadLink.folder_id.in_(folder_ids),
+ or_(DownloadLink.expires_at.is_(None), DownloadLink.expires_at > now),
+ )
+ ).scalars()
+ return {folder_id for folder_id in rows if folder_id is not None}
+
+
@router.get("/folders")
def list_folders(
caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db)
@@ -388,7 +403,8 @@ def list_folders(
.group_by(FileFolder.id)
.order_by(FileFolder.kind.desc(), FileFolder.source_key, FileFolder.name, FileFolder.id)
).all()
- return [_serialize_folder(f, count, int(size)) for f, count, size in rows]
+ linked = _linked(db, [f.id for f, _, _ in rows])
+ return [_serialize_folder(f, count, int(size), f.id in linked) for f, count, size in rows]
class FolderIn(BaseModel):
@@ -471,7 +487,7 @@ def update_folder(
)
db.commit()
count, size = _folder_totals(db, folder.id)
- return _serialize_folder(folder, count, size)
+ return _serialize_folder(folder, count, size, folder.id in _linked(db, [folder.id]))
def _folder_totals(db: Session, folder_id: int) -> tuple[int, int]:
diff --git a/backend/app/routes/public.py b/backend/app/routes/public.py
index b4a9dc71..1dd34877 100644
--- a/backend/app/routes/public.py
+++ b/backend/app/routes/public.py
@@ -9,6 +9,7 @@ a short-lived signed grant that the media URL carries (a `