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:
@@ -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}
|
||||
|
||||
@@ -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
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user