feat(files): share-link tokens are kept only hashed and encrypted, and every link change is audited
A /watch link's token is the credential, and it sat in the database as written. Migration 0107 replaces the column with the token's SHA-256 (the lookup) and a Fernet-encrypted copy (so the owner can copy the same link again); every existing address keeps working, and the downgrade decrypts them back. Without a copy this key can read, a link still opens but its address cannot be shown: "Copy link" then takes the newest link that has one or makes a new one, and the Share dialog says so in that row instead of a broken URL. Making, changing and revoking a link are audited, never with its token or password.
This commit is contained in:
@@ -0,0 +1,66 @@
|
|||||||
|
"""Share-link tokens no longer stored in the clear
|
||||||
|
|
||||||
|
Revision ID: 0107_link_token_at_rest
|
||||||
|
Revises: 0106_api_tokens
|
||||||
|
Create Date: 2026-10-15
|
||||||
|
|
||||||
|
A `/watch` link's token is the credential itself, and it sat in `download_links.token` as written,
|
||||||
|
so a copy of the database was every shared file. Now a link is found by the token's SHA-256
|
||||||
|
(`token_hash`), and the token is kept only Fernet-encrypted (`token_enc`, TOKEN_ENCRYPTION_KEY) so its
|
||||||
|
owner can copy the same link again. Without a key the copy is not kept: the link still opens, its
|
||||||
|
address just cannot be shown again (`app/downloads/links.py`).
|
||||||
|
|
||||||
|
Every existing link is converted here, so the addresses already sent keep working. The downgrade
|
||||||
|
writes the tokens back from their encrypted copies; a link with none left (no key, or a key since
|
||||||
|
changed) gets a fresh random token, which is the same as revoking it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
from app import security
|
||||||
|
|
||||||
|
revision: str = "0107_link_token_at_rest"
|
||||||
|
down_revision: str | None = "0106_api_tokens"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("download_links", sa.Column("token_hash", sa.String(64)))
|
||||||
|
op.add_column("download_links", sa.Column("token_enc", sa.Text()))
|
||||||
|
conn = op.get_bind()
|
||||||
|
sealing = security.can_encrypt()
|
||||||
|
for link_id, token in conn.execute(sa.text("SELECT id, token FROM download_links")).fetchall():
|
||||||
|
conn.execute(
|
||||||
|
sa.text("UPDATE download_links SET token_hash = :h, token_enc = :e WHERE id = :id"),
|
||||||
|
{
|
||||||
|
"id": link_id,
|
||||||
|
"h": hashlib.sha256(token.encode()).hexdigest(),
|
||||||
|
"e": security.encrypt(token) if sealing else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
op.alter_column("download_links", "token_hash", nullable=False)
|
||||||
|
op.create_unique_constraint("uq_download_link_token_hash", "download_links", ["token_hash"])
|
||||||
|
op.drop_constraint("uq_download_link_token", "download_links", type_="unique")
|
||||||
|
op.drop_column("download_links", "token")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column("download_links", sa.Column("token", sa.String(64)))
|
||||||
|
conn = op.get_bind()
|
||||||
|
rows = conn.execute(sa.text("SELECT id, token_enc FROM download_links")).fetchall()
|
||||||
|
for link_id, enc in rows:
|
||||||
|
token = security.decrypt_optional(enc) or secrets.token_urlsafe(24)
|
||||||
|
conn.execute(
|
||||||
|
sa.text("UPDATE download_links SET token = :t WHERE id = :id"),
|
||||||
|
{"id": link_id, "t": token},
|
||||||
|
)
|
||||||
|
op.alter_column("download_links", "token", nullable=False)
|
||||||
|
op.create_unique_constraint("uq_download_link_token", "download_links", ["token"])
|
||||||
|
op.drop_constraint("uq_download_link_token_hash", "download_links", type_="unique")
|
||||||
|
op.drop_column("download_links", "token_enc")
|
||||||
|
op.drop_column("download_links", "token_hash")
|
||||||
@@ -73,6 +73,11 @@ class AuditAction:
|
|||||||
# What a token then did is the file rows above, their summary naming it.
|
# What a token then did is the file rows above, their summary naming it.
|
||||||
API_TOKEN_CREATE = "api_token_create"
|
API_TOKEN_CREATE = "api_token_create"
|
||||||
API_TOKEN_REVOKE = "api_token_revoke"
|
API_TOKEN_REVOKE = "api_token_revoke"
|
||||||
|
# A public `/watch` link to a file (`routes.downloads`): made, its settings changed, revoked.
|
||||||
|
# Never its token or password — only whether it has one.
|
||||||
|
DOWNLOAD_LINK_CREATE = "download_link_create"
|
||||||
|
DOWNLOAD_LINK_UPDATE = "download_link_update"
|
||||||
|
DOWNLOAD_LINK_REVOKE = "download_link_revoke"
|
||||||
|
|
||||||
|
|
||||||
def record(
|
def record(
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
"""Public "share by link" helpers for the download center.
|
"""Public "share by link" helpers for the download center.
|
||||||
|
|
||||||
A DownloadLink is a capability URL: the unguessable `token` grants anyone read access to one
|
A DownloadLink is a capability URL: the unguessable token grants anyone read access to one
|
||||||
download's file on the login-free `/watch/{token}` page. Password-protected links additionally
|
download's file on the login-free `/watch/{token}` page. So the token is never stored as written:
|
||||||
require a short-lived signed **grant** on the file request — because a `<video src>` can't send a
|
the link is found by its SHA-256, and the owner's copy is Fernet-encrypted — a database copy alone
|
||||||
header, the password is exchanged once at `/unlock` for an HMAC grant appended to the file URL,
|
opens nothing. Without that copy (no TOKEN_ENCRYPTION_KEY, or a key since changed) the link still
|
||||||
so the raw password never rides in the media URL (or server logs).
|
opens; only its address cannot be shown again, and the owner makes a new link to share.
|
||||||
|
|
||||||
|
Password-protected links additionally require a short-lived signed **grant** on the file request —
|
||||||
|
because a `<video src>` can't send a header, the password is exchanged once at `/unlock` for an HMAC
|
||||||
|
grant appended to the file URL, so the raw password never rides in the media URL (or server logs).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -12,6 +16,10 @@ import hmac
|
|||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import security
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import DownloadLink
|
from app.models import DownloadLink
|
||||||
|
|
||||||
@@ -22,6 +30,24 @@ def new_token() -> str:
|
|||||||
return secrets.token_urlsafe(24) # ~32 chars, 192 bits
|
return secrets.token_urlsafe(24) # ~32 chars, 192 bits
|
||||||
|
|
||||||
|
|
||||||
|
def seal(link: DownloadLink, token: str) -> None:
|
||||||
|
"""Store `token` on `link` the only ways it is kept: its hash, and its encrypted copy."""
|
||||||
|
link.token_hash = security.hash_token(token)
|
||||||
|
link.token_enc = security.encrypt(token) if security.can_encrypt() else None
|
||||||
|
|
||||||
|
|
||||||
|
def find(db: Session, token: str) -> DownloadLink | None:
|
||||||
|
"""The link a `/watch` token names, by its hash."""
|
||||||
|
return db.execute(
|
||||||
|
select(DownloadLink).where(DownloadLink.token_hash == security.hash_token(token))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def recover(link: DownloadLink) -> str | None:
|
||||||
|
"""The link's token from its encrypted copy, or None when there is none this key can read."""
|
||||||
|
return security.decrypt_optional(link.token_enc)
|
||||||
|
|
||||||
|
|
||||||
def is_expired(link: DownloadLink) -> bool:
|
def is_expired(link: DownloadLink) -> bool:
|
||||||
return link.expires_at is not None and link.expires_at <= datetime.now(timezone.utc)
|
return link.expires_at is not None and link.expires_at <= datetime.now(timezone.utc)
|
||||||
|
|
||||||
@@ -61,12 +87,14 @@ def check_grant(token: str, grant: str | None, password_version: int) -> bool:
|
|||||||
# --- serialization -------------------------------------------------------------------------
|
# --- serialization -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def owner_view(link: DownloadLink, base_url: str = "") -> dict:
|
def owner_view(link: DownloadLink, base_url: str = "", token: str | None = None) -> dict:
|
||||||
"""What the file owner sees when managing their links."""
|
"""What the file owner sees when managing their links. `token` is the one just made (it is in
|
||||||
|
hand even where no copy is kept); else it is read back, and `url` is None when it cannot be."""
|
||||||
|
token = token or recover(link)
|
||||||
return {
|
return {
|
||||||
"id": link.id,
|
"id": link.id,
|
||||||
"token": link.token,
|
"token": token,
|
||||||
"url": f"{base_url}/watch/{link.token}",
|
"url": f"{base_url}/watch/{token}" if token else None,
|
||||||
"allow_download": link.allow_download,
|
"allow_download": link.allow_download,
|
||||||
"has_password": link.password_hash is not None,
|
"has_password": link.password_hash is not None,
|
||||||
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
|
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
|
||||||
|
|||||||
@@ -11,13 +11,12 @@ import html
|
|||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads import links as linksmod
|
from app.downloads import links as linksmod
|
||||||
from app.downloads import storage
|
from app.downloads import storage
|
||||||
from app.models import DownloadJob, DownloadLink, MediaAsset
|
from app.models import DownloadJob, MediaAsset
|
||||||
|
|
||||||
_OG_BLOCK = re.compile(r"<!--OG:START-->.*?<!--OG:END-->", re.DOTALL)
|
_OG_BLOCK = re.compile(r"<!--OG:START-->.*?<!--OG:END-->", re.DOTALL)
|
||||||
|
|
||||||
@@ -74,7 +73,7 @@ def _jpeg_size(path: Path) -> tuple[int, int] | None:
|
|||||||
|
|
||||||
def _watch_tags(token: str, db: Session) -> str | None:
|
def _watch_tags(token: str, db: Session) -> str | None:
|
||||||
"""Build the OG/Twitter meta block for a watch token, or None to keep the generic card."""
|
"""Build the OG/Twitter meta block for a watch token, or None to keep the generic card."""
|
||||||
link = db.execute(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none()
|
link = linksmod.find(db, token)
|
||||||
if link is None or linksmod.is_expired(link):
|
if link is None or linksmod.is_expired(link):
|
||||||
return None
|
return None
|
||||||
url = f"{settings.app_base}/watch/{token}"
|
url = f"{settings.app_base}/watch/{token}"
|
||||||
|
|||||||
@@ -1131,8 +1131,10 @@ class DownloadQuota(Base, UpdatedAtMixin):
|
|||||||
class DownloadLink(Base, TimestampMixin):
|
class DownloadLink(Base, TimestampMixin):
|
||||||
"""A public capability link to one download — the "share by link" surface.
|
"""A public capability link to one download — the "share by link" surface.
|
||||||
|
|
||||||
Anyone holding the unguessable `token` can watch the file on the login-free `/watch/{token}`
|
Anyone holding the unguessable token can watch the file on the login-free `/watch/{token}`
|
||||||
player page (Google-Drive style). Optional per-link controls: `expires_at`, `allow_download`
|
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 =
|
(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
|
delete the row. Distinct from `DownloadShare`, which grants a *registered* user access via the
|
||||||
in-app "Shared with me" list."""
|
in-app "Shared with me" list."""
|
||||||
@@ -1143,7 +1145,8 @@ class DownloadLink(Base, TimestampMixin):
|
|||||||
job_id: Mapped[int] = mapped_column(
|
job_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
|
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
token: Mapped[str] = mapped_column(String(64), unique=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"))
|
created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
allow_download: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
allow_download: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
password_hash: Mapped[str | None] = mapped_column(Text)
|
password_hash: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ from app.routes.files import (
|
|||||||
)
|
)
|
||||||
from app.security import hash_password
|
from app.security import hash_password
|
||||||
from app.userscope import is_messageable_user, messageable_clauses
|
from app.userscope import is_messageable_user, messageable_clauses
|
||||||
|
from app.utils import iso
|
||||||
from app.refusal import refused
|
from app.refusal import refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
|
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
|
||||||
@@ -769,18 +770,37 @@ def create_link(
|
|||||||
if job.status != JobStatus.DONE or job.asset_id is None:
|
if job.status != JobStatus.DONE or job.asset_id is None:
|
||||||
raise refused(409, "share_unfinished")
|
raise refused(409, "share_unfinished")
|
||||||
pw = body.password.strip()
|
pw = body.password.strip()
|
||||||
|
token = linksmod.new_token()
|
||||||
link = DownloadLink(
|
link = DownloadLink(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
token=linksmod.new_token(),
|
|
||||||
created_by=user.id,
|
created_by=user.id,
|
||||||
allow_download=body.allow_download,
|
allow_download=body.allow_download,
|
||||||
password_hash=hash_password(pw) if pw else None,
|
password_hash=hash_password(pw) if pw else None,
|
||||||
expires_at=_link_expiry(body.expires_days),
|
expires_at=_link_expiry(body.expires_days),
|
||||||
)
|
)
|
||||||
|
linksmod.seal(link, token)
|
||||||
db.add(link)
|
db.add(link)
|
||||||
|
db.flush()
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
user.id,
|
||||||
|
audit.AuditAction.DOWNLOAD_LINK_CREATE,
|
||||||
|
target_type="download_link",
|
||||||
|
target_id=link.id,
|
||||||
|
after={"job_id": job_id, **_link_audit(link)},
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(link)
|
db.refresh(link)
|
||||||
return linksmod.owner_view(link)
|
return linksmod.owner_view(link, token=token)
|
||||||
|
|
||||||
|
|
||||||
|
def _link_audit(link: DownloadLink) -> dict:
|
||||||
|
"""A link's settings as the audit records them: whether it has a password, never which."""
|
||||||
|
return {
|
||||||
|
"allow_download": link.allow_download,
|
||||||
|
"has_password": link.password_hash is not None,
|
||||||
|
"expires_at": iso(link.expires_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class UpdateLinkIn(BaseModel):
|
class UpdateLinkIn(BaseModel):
|
||||||
@@ -797,6 +817,7 @@ def update_link(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
link = _own_link(db, user, link_id)
|
link = _own_link(db, user, link_id)
|
||||||
|
before = _link_audit(link)
|
||||||
sent = body.model_fields_set
|
sent = body.model_fields_set
|
||||||
if "allow_download" in sent:
|
if "allow_download" in sent:
|
||||||
link.allow_download = bool(body.allow_download)
|
link.allow_download = bool(body.allow_download)
|
||||||
@@ -808,6 +829,17 @@ def update_link(
|
|||||||
# Bump the version so any grant issued under the previous password stops working now, rather
|
# Bump the version so any grant issued under the previous password stops working now, rather
|
||||||
# than living out its 6h TTL (setting/clearing/changing the password all count as a rotation).
|
# than living out its 6h TTL (setting/clearing/changing the password all count as a rotation).
|
||||||
link.password_version += 1
|
link.password_version += 1
|
||||||
|
after = _link_audit(link)
|
||||||
|
if after != before or "password" in sent:
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
user.id,
|
||||||
|
audit.AuditAction.DOWNLOAD_LINK_UPDATE,
|
||||||
|
target_type="download_link",
|
||||||
|
target_id=link_id,
|
||||||
|
before=before,
|
||||||
|
after=after,
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return linksmod.owner_view(link)
|
return linksmod.owner_view(link)
|
||||||
|
|
||||||
@@ -818,8 +850,16 @@ def revoke_link(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
link = _own_link(db, user, link_id)
|
link = _own_link(db, user, link_id)
|
||||||
db.delete(link)
|
db.delete(link)
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
user.id,
|
||||||
|
audit.AuditAction.DOWNLOAD_LINK_REVOKE,
|
||||||
|
target_type="download_link",
|
||||||
|
target_id=link_id,
|
||||||
|
after={"job_id": link.job_id},
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"deleted": link_id}
|
return {"ok": True, "deleted": link_id}
|
||||||
|
|
||||||
|
|
||||||
# --- admin ---------------------------------------------------------------------------------
|
# --- admin ---------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ a short-lived signed grant that the media URL carries (a `<video src>` can't sen
|
|||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -38,7 +37,7 @@ _unlock_failures = RateLimiter(max_events=50, window_seconds=300)
|
|||||||
|
|
||||||
|
|
||||||
def _link_or_404(db: Session, token: str) -> DownloadLink:
|
def _link_or_404(db: Session, token: str) -> DownloadLink:
|
||||||
link = db.execute(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none()
|
link = linksmod.find(db, token)
|
||||||
if link is None or linksmod.is_expired(link):
|
if link is None or linksmod.is_expired(link):
|
||||||
raise refused(404, "link_gone")
|
raise refused(404, "link_gone")
|
||||||
return link
|
return link
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ _fernet: Fernet | None = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_encrypt() -> bool:
|
||||||
|
"""Whether a value can be stored encrypted here: TOKEN_ENCRYPTION_KEY is set. Production
|
||||||
|
refuses to start without it (`config`); a local box may run without one."""
|
||||||
|
return _fernet is not None
|
||||||
|
|
||||||
|
|
||||||
def _require_fernet() -> Fernet:
|
def _require_fernet() -> Fernet:
|
||||||
if _fernet is None:
|
if _fernet is None:
|
||||||
raise RuntimeError("TOKEN_ENCRYPTION_KEY is not configured")
|
raise RuntimeError("TOKEN_ENCRYPTION_KEY is not configured")
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""A `/watch` link's token at rest (downloads/links.py, migration 0107), DB-lane.
|
||||||
|
|
||||||
|
The token is the credential, so the database keeps only its SHA-256 (to find the link by) and a
|
||||||
|
Fernet-encrypted copy (for its owner to copy the same link again). Without a copy this key can read,
|
||||||
|
the link still opens and only its address cannot be shown again. Making, changing and revoking a
|
||||||
|
link is audited, never with its token or password.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
|
from app import security
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import engine
|
||||||
|
from app.downloads import probe as probemod
|
||||||
|
from app.models import AuditLog, DownloadLink
|
||||||
|
|
||||||
|
_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 key(monkeypatch):
|
||||||
|
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def owner_job(as_user, make_user, tmp_path, monkeypatch, upload_headers):
|
||||||
|
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||||
|
monkeypatch.setattr(probemod, "probe_json", lambda _p: _VIDEO)
|
||||||
|
owner = as_user(make_user())
|
||||||
|
job = owner.post("/api/files/upload", content=b"fake-video", headers=upload_headers("clip.mp4"))
|
||||||
|
assert job.status_code == 200, job.text
|
||||||
|
return owner, job.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _row(db, link_id: int) -> dict:
|
||||||
|
db.expire_all()
|
||||||
|
row = db.execute(
|
||||||
|
text("SELECT * FROM download_links WHERE id = :id"), {"id": link_id}
|
||||||
|
).mappings()
|
||||||
|
return dict(row.one())
|
||||||
|
|
||||||
|
|
||||||
|
def _audits(db, action: str) -> list[AuditLog]:
|
||||||
|
db.expire_all()
|
||||||
|
return list(db.execute(select(AuditLog).where(AuditLog.action == action)).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_token_is_kept_only_hashed_and_encrypted(db, key, owner_job, client):
|
||||||
|
owner, job_id = owner_job
|
||||||
|
made = owner.post(f"/api/downloads/{job_id}/links", json={}).json()
|
||||||
|
token = made["token"]
|
||||||
|
assert made["url"] == f"/watch/{token}"
|
||||||
|
|
||||||
|
row = _row(db, made["id"])
|
||||||
|
assert "token" not in row
|
||||||
|
assert token not in json.dumps(row, default=str)
|
||||||
|
assert row["token_hash"] == security.hash_token(token)
|
||||||
|
assert security.decrypt(row["token_enc"]) == token
|
||||||
|
|
||||||
|
# The address it was given opens it, and its owner can copy the same one again.
|
||||||
|
assert client.get(f"/api/public/watch/{token}").status_code == 200
|
||||||
|
listed = owner.get(f"/api/downloads/{job_id}/links").json()
|
||||||
|
assert [entry["url"] for entry in listed] == [f"/watch/{token}"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_token_naming_no_link_finds_none(db, key, owner_job, client):
|
||||||
|
owner, job_id = owner_job
|
||||||
|
token = owner.post(f"/api/downloads/{job_id}/links", json={}).json()["token"]
|
||||||
|
other = token[:-1] + ("A" if token[-1] != "A" else "B")
|
||||||
|
assert client.get(f"/api/public/watch/{other}").status_code == 404
|
||||||
|
# Its hash is no key either: the lookup hashes what it is given.
|
||||||
|
hashed = security.hash_token(token)
|
||||||
|
assert client.get(f"/api/public/watch/{hashed}").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_a_key_the_link_opens_but_its_address_is_not_kept(db, owner_job, client):
|
||||||
|
assert not security.can_encrypt()
|
||||||
|
owner, job_id = owner_job
|
||||||
|
made = owner.post(f"/api/downloads/{job_id}/links", json={}).json()
|
||||||
|
# The one who made it has it in hand…
|
||||||
|
assert made["url"] == f"/watch/{made['token']}"
|
||||||
|
assert _row(db, made["id"])["token_enc"] is None
|
||||||
|
assert client.get(f"/api/public/watch/{made['token']}").status_code == 200
|
||||||
|
# …but it cannot be shown again.
|
||||||
|
listed = owner.get(f"/api/downloads/{job_id}/links").json()
|
||||||
|
assert [(entry["token"], entry["url"]) for entry in listed] == [(None, None)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_changed_key_hides_the_address_and_the_link_still_opens(
|
||||||
|
db, key, owner_job, client, monkeypatch
|
||||||
|
):
|
||||||
|
owner, job_id = owner_job
|
||||||
|
token = owner.post(f"/api/downloads/{job_id}/links", json={}).json()["token"]
|
||||||
|
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
|
||||||
|
listed = owner.get(f"/api/downloads/{job_id}/links").json()
|
||||||
|
assert [entry["url"] for entry in listed] == [None]
|
||||||
|
assert client.get(f"/api/public/watch/{token}").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_making_changing_and_revoking_a_link_is_audited_without_its_secrets(db, key, owner_job):
|
||||||
|
owner, job_id = owner_job
|
||||||
|
pw = "a secret word"
|
||||||
|
made = owner.post(
|
||||||
|
f"/api/downloads/{job_id}/links", json={"password": pw, "allow_download": False}
|
||||||
|
).json()
|
||||||
|
link_id = made["id"]
|
||||||
|
patch = f"/api/downloads/links/{link_id}"
|
||||||
|
assert owner.patch(patch, json={"allow_download": True}).status_code == 200
|
||||||
|
assert owner.patch(patch, json={"password": "another one"}).status_code == 200
|
||||||
|
# Nothing changed: nothing to record.
|
||||||
|
assert owner.patch(patch, json={"allow_download": True}).status_code == 200
|
||||||
|
gone = owner.delete(f"/api/downloads/links/{link_id}")
|
||||||
|
assert gone.json() == {"ok": True, "deleted": link_id}
|
||||||
|
|
||||||
|
(create,) = _audits(db, "download_link_create")
|
||||||
|
assert create.target_id == str(link_id)
|
||||||
|
assert create.after == {
|
||||||
|
"job_id": job_id,
|
||||||
|
"allow_download": False,
|
||||||
|
"has_password": True,
|
||||||
|
"expires_at": None,
|
||||||
|
}
|
||||||
|
updates = _audits(db, "download_link_update")
|
||||||
|
assert [(u.before["allow_download"], u.after["allow_download"]) for u in updates] == [
|
||||||
|
(False, True),
|
||||||
|
(True, True), # the password changed: a rotation is recorded though the flags are equal
|
||||||
|
]
|
||||||
|
(revoke,) = _audits(db, "download_link_revoke")
|
||||||
|
assert revoke.target_id == str(link_id)
|
||||||
|
assert revoke.after == {"job_id": job_id}
|
||||||
|
|
||||||
|
trail = json.dumps(
|
||||||
|
[[a.summary, a.before, a.after] for a in [create, *updates, revoke]], default=str
|
||||||
|
)
|
||||||
|
for secret in (made["token"], pw, "another one"):
|
||||||
|
assert secret not in trail
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_migration_keeps_every_address_already_sent(db, key, owner_job, client):
|
||||||
|
owner, job_id = owner_job
|
||||||
|
db.commit() # an open transaction's locks would hold the downgrade's ALTER TABLE forever
|
||||||
|
cfg = Config("alembic.ini")
|
||||||
|
command.downgrade(cfg, "0106_api_tokens")
|
||||||
|
try:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text("INSERT INTO download_links (job_id, token) VALUES (:j, 'an-old-token-sent')"),
|
||||||
|
{"j": job_id},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
|
||||||
|
(row,) = db.execute(text("SELECT token_hash, token_enc FROM download_links")).mappings().all()
|
||||||
|
assert row["token_hash"] == security.hash_token("an-old-token-sent")
|
||||||
|
assert security.decrypt(row["token_enc"]) == "an-old-token-sent"
|
||||||
|
assert client.get("/api/public/watch/an-old-token-sent").status_code == 200
|
||||||
|
listed = owner.get(f"/api/downloads/{job_id}/links").json()
|
||||||
|
assert [entry["url"] for entry in listed] == ["/watch/an-old-token-sent"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_downgrade_writes_the_tokens_back(db, key, owner_job):
|
||||||
|
owner, job_id = owner_job
|
||||||
|
token = owner.post(f"/api/downloads/{job_id}/links", json={}).json()["token"]
|
||||||
|
db.commit() # an open transaction's locks would hold the downgrade's ALTER TABLE forever
|
||||||
|
cfg = Config("alembic.ini")
|
||||||
|
command.downgrade(cfg, "0106_api_tokens")
|
||||||
|
try:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
back = conn.execute(text("SELECT token FROM download_links")).scalars().all()
|
||||||
|
finally:
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
assert back == [token]
|
||||||
|
assert db.execute(select(DownloadLink.token_hash)).scalars().all() == [
|
||||||
|
security.hash_token(token)
|
||||||
|
]
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
// Revoke is the destructive one. Both colours once sat on the button, where the ghost variant's own
|
// Revoke is the destructive one. Both colours once sat on the button, where the ghost variant's own
|
||||||
// colour class outranks them (clsx does not merge Tailwind), so a password-protected link looked
|
// 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.
|
// unprotected and Revoke hovered neutral. They live on the ICON now; this pins them there.
|
||||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||||
import { render, screen, cleanup } from "@testing-library/react";
|
import { render, screen, cleanup } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
@@ -17,31 +17,33 @@ vi.mock("react-i18next", async (importOriginal) => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("./ui/Modal", () => ({ default: ({ children }: { children: ReactNode }) => children }));
|
vi.mock("./ui/Modal", () => ({ default: ({ children }: { children: ReactNode }) => children }));
|
||||||
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => false }));
|
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => false }));
|
||||||
|
const h = vi.hoisted(() => ({ links: null as unknown[] | null }));
|
||||||
vi.mock("../lib/api", () => ({
|
vi.mock("../lib/api", () => ({
|
||||||
api: {
|
api: {
|
||||||
shareRecipients: async () => [],
|
shareRecipients: async () => [],
|
||||||
downloadLinks: async () => [
|
downloadLinks: async () =>
|
||||||
{
|
h.links ?? [
|
||||||
id: 1,
|
{
|
||||||
token: "a",
|
id: 1,
|
||||||
url: "/watch/a",
|
token: "a",
|
||||||
allow_download: false,
|
url: "/watch/a",
|
||||||
has_password: true,
|
allow_download: false,
|
||||||
expires_at: null,
|
has_password: true,
|
||||||
view_count: 0,
|
expires_at: null,
|
||||||
created_at: null,
|
view_count: 0,
|
||||||
},
|
created_at: null,
|
||||||
{
|
},
|
||||||
id: 2,
|
{
|
||||||
token: "b",
|
id: 2,
|
||||||
url: "/watch/b",
|
token: "b",
|
||||||
allow_download: false,
|
url: "/watch/b",
|
||||||
has_password: false,
|
allow_download: false,
|
||||||
expires_at: null,
|
has_password: false,
|
||||||
view_count: 0,
|
expires_at: null,
|
||||||
created_at: null,
|
view_count: 0,
|
||||||
},
|
created_at: null,
|
||||||
],
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -49,6 +51,9 @@ import ShareDialog from "./ShareDialog";
|
|||||||
import type { DownloadJob } from "../lib/api";
|
import type { DownloadJob } from "../lib/api";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
beforeEach(() => {
|
||||||
|
h.links = null;
|
||||||
|
});
|
||||||
|
|
||||||
const iconClass = (button: HTMLElement) => button.querySelector("svg")?.getAttribute("class") ?? "";
|
const iconClass = (button: HTMLElement) => button.querySelector("svg")?.getAttribute("class") ?? "";
|
||||||
|
|
||||||
@@ -71,3 +76,33 @@ describe("ShareDialog · a link row's state colours", () => {
|
|||||||
expect(iconClass(revoke)).toContain("text-danger");
|
expect(iconClass(revoke)).toContain("text-danger");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ShareDialog · a link whose address cannot be shown again", () => {
|
||||||
|
it("says so in place of the address, with nothing to copy", async () => {
|
||||||
|
const link = (id: number, url: string | null) => ({
|
||||||
|
id,
|
||||||
|
token: url && url.slice("/watch/".length),
|
||||||
|
url,
|
||||||
|
allow_download: false,
|
||||||
|
has_password: false,
|
||||||
|
expires_at: null,
|
||||||
|
view_count: 0,
|
||||||
|
created_at: null,
|
||||||
|
});
|
||||||
|
h.links = [link(3, null), link(2, "/watch/b")];
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<ShareDialog job={{ id: 9 } as DownloadJob} onClose={() => {}} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("downloads.share.urlHidden")).toBeTruthy();
|
||||||
|
// One row has an address to show and copy; the hidden one shows none (not "…/null").
|
||||||
|
expect(screen.getAllByRole("button", { name: "downloads.share.copy" })).toHaveLength(1);
|
||||||
|
const shown = screen.getAllByRole("textbox").map((el) => (el as HTMLInputElement).value);
|
||||||
|
expect(shown).toContain(`${window.location.origin}/watch/b`);
|
||||||
|
expect(shown.some((v) => v.includes("null"))).toBe(false);
|
||||||
|
// Its other controls stay: it can still be protected, made downloadable, or revoked.
|
||||||
|
expect(screen.getAllByRole("button", { name: "downloads.share.revoke" })).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -70,10 +70,10 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
|||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [editPass, setEditPass] = useState(false);
|
const [editPass, setEditPass] = useState(false);
|
||||||
const [newPass, setNewPass] = useState("");
|
const [newPass, setNewPass] = useState("");
|
||||||
const fullUrl = window.location.origin + link.url;
|
const fullUrl = link.url && window.location.origin + link.url;
|
||||||
|
|
||||||
const copy = async () => {
|
const copy = async () => {
|
||||||
if (await copyText(fullUrl)) {
|
if (fullUrl && (await copyText(fullUrl))) {
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 1500);
|
setTimeout(() => setCopied(false), 1500);
|
||||||
} else {
|
} else {
|
||||||
@@ -114,10 +114,20 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
|||||||
<div className={`${SURFACE.card} p-2.5 space-y-2`}>
|
<div className={`${SURFACE.card} p-2.5 space-y-2`}>
|
||||||
<div className="flex items-center gap-2 rounded-md focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
|
<div className="flex items-center gap-2 rounded-md focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
|
||||||
<Link2 className="w-4 h-4 text-muted shrink-0" />
|
<Link2 className="w-4 h-4 text-muted shrink-0" />
|
||||||
<InnerInput className="text-xs text-muted truncate" readOnly value={fullUrl} />
|
{fullUrl ? (
|
||||||
<IconButton label={t("downloads.share.copy")} size="sm" variant="ghost" onClick={copy}>
|
<>
|
||||||
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
|
<InnerInput className="text-xs text-muted truncate" readOnly value={fullUrl} />
|
||||||
</IconButton>
|
<IconButton label={t("downloads.share.copy")} size="sm" variant="ghost" onClick={copy}>
|
||||||
|
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// The server keeps no copy of this link's address it can read: the link still opens for
|
||||||
|
// whoever has it, but it cannot be shared again from here.
|
||||||
|
<span className="flex-1 min-w-0 text-xs text-muted">
|
||||||
|
{t("downloads.share.urlHidden")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{/* All three on the same rung. Only Copy was migrated at first, which left one 2rem button
|
{/* All three on the same rung. Only Copy was migrated at first, which left one 2rem button
|
||||||
between two 1.75rem ones with a different radius and hover fill — and, on a coarse
|
between two 1.75rem ones with a different radius and hover fill — and, on a coarse
|
||||||
pointer, `.touch-target` grew ONLY the migrated one to 2.75rem while the destructive
|
pointer, `.touch-target` grew ONLY the migrated one to 2.75rem while the destructive
|
||||||
|
|||||||
@@ -33,5 +33,8 @@
|
|||||||
"file_folder_retention": "Folder retention changed",
|
"file_folder_retention": "Folder retention changed",
|
||||||
"file_folder_delete": "Folder deleted",
|
"file_folder_delete": "Folder deleted",
|
||||||
"api_token_create": "API token created",
|
"api_token_create": "API token created",
|
||||||
"api_token_revoke": "API token revoked"
|
"api_token_revoke": "API token revoked",
|
||||||
|
"download_link_create": "Share link created",
|
||||||
|
"download_link_update": "Share link changed",
|
||||||
|
"download_link_revoke": "Share link revoked"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@
|
|||||||
"password": "Password (optional)",
|
"password": "Password (optional)",
|
||||||
"copy": "Copy link",
|
"copy": "Copy link",
|
||||||
"copyFailed": "Couldn’t copy",
|
"copyFailed": "Couldn’t copy",
|
||||||
|
"urlHidden": "This link still opens, but its address can no longer be shown. To share the file again, make a new link.",
|
||||||
"revoke": "Revoke",
|
"revoke": "Revoke",
|
||||||
"revokeConfirm": "This link will stop working immediately.",
|
"revokeConfirm": "This link will stop working immediately.",
|
||||||
"views_one": "{{count}} view",
|
"views_one": "{{count}} view",
|
||||||
|
|||||||
@@ -33,5 +33,8 @@
|
|||||||
"file_folder_retention": "Mappa megőrzési ideje módosítva",
|
"file_folder_retention": "Mappa megőrzési ideje módosítva",
|
||||||
"file_folder_delete": "Mappa törölve",
|
"file_folder_delete": "Mappa törölve",
|
||||||
"api_token_create": "API-token létrehozva",
|
"api_token_create": "API-token létrehozva",
|
||||||
"api_token_revoke": "API-token visszavonva"
|
"api_token_revoke": "API-token visszavonva",
|
||||||
|
"download_link_create": "Megosztási link létrehozva",
|
||||||
|
"download_link_update": "Megosztási link módosítva",
|
||||||
|
"download_link_revoke": "Megosztási link visszavonva"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@
|
|||||||
"password": "Jelszó (opcionális)",
|
"password": "Jelszó (opcionális)",
|
||||||
"copy": "Link másolása",
|
"copy": "Link másolása",
|
||||||
"copyFailed": "Nem sikerült másolni",
|
"copyFailed": "Nem sikerült másolni",
|
||||||
|
"urlHidden": "Ez a link továbbra is működik, de a címe már nem jeleníthető meg. Ha újra meg akarod osztani a fájlt, készíts új linket.",
|
||||||
"revoke": "Visszavonás",
|
"revoke": "Visszavonás",
|
||||||
"revokeConfirm": "A link azonnal használhatatlanná válik.",
|
"revokeConfirm": "A link azonnal használhatatlanná válik.",
|
||||||
"views_one": "{{count}} megtekintés",
|
"views_one": "{{count}} megtekintés",
|
||||||
|
|||||||
@@ -140,8 +140,10 @@ export interface ShareRecipient {
|
|||||||
|
|
||||||
export interface ShareLink {
|
export interface ShareLink {
|
||||||
id: number;
|
id: number;
|
||||||
token: string;
|
// Null when the server cannot show this link's address again (no encrypted copy its key reads): the
|
||||||
url: string; // path like "/watch/<token>" — prepend window.location.origin to share
|
// link still opens for whoever has it; to share it anew, make a new link.
|
||||||
|
token: string | null;
|
||||||
|
url: string | null; // path like "/watch/<token>" — prepend window.location.origin to share
|
||||||
allow_download: boolean;
|
allow_download: boolean;
|
||||||
has_password: boolean;
|
has_password: boolean;
|
||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
@@ -266,7 +268,7 @@ export const downloadsApi = {
|
|||||||
patch: { allow_download?: boolean; expires_days?: number | null; password?: string },
|
patch: { allow_download?: boolean; expires_days?: number | null; password?: string },
|
||||||
): Promise<ShareLink> =>
|
): Promise<ShareLink> =>
|
||||||
req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
revokeDownloadLink: (linkId: number): Promise<{ deleted: number }> =>
|
revokeDownloadLink: (linkId: number): Promise<{ ok: true; deleted: number }> =>
|
||||||
req(`/api/downloads/links/${linkId}`, { method: "DELETE" }),
|
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.
|
// The library player's resume checkpoint. A position too early or too close to the end clears it.
|
||||||
saveDownloadProgress: (
|
saveDownloadProgress: (
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ vi.mock("./clipboard", () => ({ copyText }));
|
|||||||
vi.mock("./notifications", () => ({ notify }));
|
vi.mock("./notifications", () => ({ notify }));
|
||||||
|
|
||||||
import { quickShareLink } from "./downloads";
|
import { quickShareLink } from "./downloads";
|
||||||
|
import { qk } from "./queryKeys";
|
||||||
|
|
||||||
const t = (k: string) => k;
|
const t = (k: string) => k;
|
||||||
const origin = window.location.origin;
|
const origin = window.location.origin;
|
||||||
@@ -71,6 +72,34 @@ describe("quickShareLink", () => {
|
|||||||
expect(firstToast().level).toBe("success");
|
expect(firstToast().level).toBe("success");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes over a link whose address the server can no longer show", async () => {
|
||||||
|
downloadLinks.mockResolvedValue([
|
||||||
|
{ id: 4, url: null },
|
||||||
|
{ id: 3, url: "/watch/older" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const url = await quickShareLink(qc(), { id: 1 }, t);
|
||||||
|
|
||||||
|
expect(url).toBe(`${origin}/watch/older`);
|
||||||
|
expect(createDownloadLink).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mints a new link when none it has can be shown, keeping the old ones listed", async () => {
|
||||||
|
const hidden = { id: 4, url: null };
|
||||||
|
downloadLinks.mockResolvedValue([hidden]);
|
||||||
|
createDownloadLink.mockResolvedValue({ id: 8, url: "/watch/new" });
|
||||||
|
const client = qc();
|
||||||
|
|
||||||
|
const url = await quickShareLink(client, { id: 6 }, t);
|
||||||
|
|
||||||
|
expect(url).toBe(`${origin}/watch/new`);
|
||||||
|
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/new`);
|
||||||
|
expect(client.getQueryData(qk.downloadLinks(6))).toEqual([
|
||||||
|
{ id: 8, url: "/watch/new" },
|
||||||
|
hidden,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("still toasts (warning) with the open action when the clipboard copy fails", async () => {
|
it("still toasts (warning) with the open action when the clipboard copy fails", async () => {
|
||||||
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]);
|
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]);
|
||||||
copyText.mockResolvedValue(false);
|
copyText.mockResolvedValue(false);
|
||||||
|
|||||||
@@ -27,22 +27,25 @@ export async function quickShareLink(
|
|||||||
job: { id: number },
|
job: { id: number },
|
||||||
t: (key: string) => string,
|
t: (key: string) => string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
let link;
|
let path;
|
||||||
try {
|
try {
|
||||||
const existing = await qc.fetchQuery({
|
const existing = await qc.fetchQuery({
|
||||||
queryKey: qk.downloadLinks(job.id),
|
queryKey: qk.downloadLinks(job.id),
|
||||||
queryFn: () => api.downloadLinks(job.id),
|
queryFn: () => api.downloadLinks(job.id),
|
||||||
});
|
});
|
||||||
link = existing[0];
|
// The newest link whose address can still be shown; one that cannot is no use to copy.
|
||||||
if (!link) {
|
path = existing.find((l) => l.url)?.url;
|
||||||
link = await api.createDownloadLink(job.id, {}); // omit allow_download → backend default (true)
|
if (!path) {
|
||||||
qc.setQueryData(qk.downloadLinks(job.id), [link]);
|
const made = await api.createDownloadLink(job.id, {}); // omit allow_download → backend default (true)
|
||||||
|
qc.setQueryData(qk.downloadLinks(job.id), [made, ...existing]);
|
||||||
|
path = made.url;
|
||||||
}
|
}
|
||||||
|
if (!path) throw new Error("the new link came back without its address");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notifyActionError(e, t("downloads.share.linkFailed"));
|
notifyActionError(e, t("downloads.share.linkFailed"));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const url = window.location.origin + link.url;
|
const url = window.location.origin + path;
|
||||||
const action = {
|
const action = {
|
||||||
label: t("downloads.share.openNewTab"),
|
label: t("downloads.share.openNewTab"),
|
||||||
onClick: () => window.open(url, "_blank", "noopener,noreferrer"),
|
onClick: () => window.open(url, "_blank", "noopener,noreferrer"),
|
||||||
|
|||||||
Reference in New Issue
Block a user