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