feat(share): a link's address says what it opens
A video stays on /watch/, anything else is given out on /file/ and a folder on /folder/. The token is the whole key, so every path opens every link and the links already sent keep working. The new paths are kept out of search indexes like /watch/, boot the player alone, and the unfurl card calls a file a file.
This commit is contained in:
@@ -74,7 +74,7 @@ only along a declared edge and writes them only through that unit's service; cor
|
||||
cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by `begin()`) is review's to catch, listed in
|
||||
[`backend/app/README.md`](backend/app/README.md) with the conventions.
|
||||
Frontend, by folder under `src/`: `main.tsx` provider tree → `shell/` (`App`, header, nav rail, palette,
|
||||
boot; a shared `/watch` link boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
|
||||
boot; a share link (`/watch`, `/file`, `/folder`) boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
|
||||
app-free by a contract test) → **`modules/<page>/`** (one folder per page: the page, its rail, its store, its tests,
|
||||
and its `module.ts` definition — label, icon, gate, the lazy page and rail, its store — collected in nav order by
|
||||
`features/navigation/moduleRegistry`, which also mounts every store at the root; a new page is its folder, its id in
|
||||
|
||||
@@ -127,14 +127,28 @@ def check_grant(token: str, grant: str | None, password_version: int) -> bool:
|
||||
# --- serialization -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def owner_view(link: DownloadLink, base_url: str = "", token: str | None = None) -> dict:
|
||||
def share_path(db: Session, link: DownloadLink, token: str) -> str:
|
||||
"""The address a link is given out on: its path says what it opens. Only a word for the reader,
|
||||
the token is the whole key: every prefix of `main.SHARE_PREFIXES` opens every link, so the links
|
||||
already sent on /watch/ work for good. A file still being made reads as the video it mostly is."""
|
||||
from app.downloads import mediakind
|
||||
|
||||
if link.folder_id is not None:
|
||||
return f"/folder/{token}"
|
||||
job = db.get(DownloadJob, link.job_id)
|
||||
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
||||
kind = asset.media_kind if asset else None
|
||||
return f"/file/{token}" if kind and kind != mediakind.VIDEO else f"/watch/{token}"
|
||||
|
||||
|
||||
def owner_view(db: Session, link: DownloadLink, token: str | None = None) -> dict:
|
||||
"""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 {
|
||||
"id": link.id,
|
||||
"token": token,
|
||||
"url": f"{base_url}/watch/{token}" if token else None,
|
||||
"url": share_path(db, link, token) if token else None,
|
||||
"allow_download": link.allow_download,
|
||||
"has_password": link.password_hash is not None,
|
||||
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Server-rendered Open Graph tags for the public /watch/{token} page.
|
||||
"""Server-rendered Open Graph tags for the public page of a share link (/watch/, /file/, /folder/).
|
||||
|
||||
The watch page is a client-side SPA, so a social crawler (facebookexternalhit, Twitterbot, …) that
|
||||
fetches /watch/{token} would otherwise only see the generic index.html — a blank/greyed link card.
|
||||
@@ -76,16 +76,21 @@ def _watch_tags(token: str, db: Session) -> str | None:
|
||||
link = linksmod.find(db, token)
|
||||
if link is None or linksmod.is_expired(link):
|
||||
return None
|
||||
url = f"{settings.app_base}/watch/{token}"
|
||||
url = f"{settings.app_base}{linksmod.share_path(db, link, token)}"
|
||||
folder = link.folder_id is not None
|
||||
# What the card calls it, as its address does (`links.share_path`).
|
||||
video = "/watch/" in url
|
||||
if link.password_hash:
|
||||
# Password-gated: don't leak the title/thumbnail in the unfurl.
|
||||
return "\n ".join(
|
||||
[
|
||||
"<title>Siftlode</title>",
|
||||
_tag("og:title", "Password-protected " + ("folder" if folder else "video")),
|
||||
_tag(
|
||||
"og:title",
|
||||
"Password-protected " + ("folder" if folder else "video" if video else "file"),
|
||||
),
|
||||
_tag("og:description", "Shared via Siftlode"),
|
||||
_tag("og:type", "website" if folder else "video.other"),
|
||||
_tag("og:type", "video.other" if video else "website"),
|
||||
_tag("og:url", url),
|
||||
_tag("og:site_name", "Siftlode"),
|
||||
]
|
||||
@@ -96,7 +101,7 @@ def _watch_tags(token: str, db: Session) -> str | None:
|
||||
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":
|
||||
return None
|
||||
title = (job.display_name or asset.title or "Video").strip()
|
||||
title = (job.display_name or asset.title or ("Video" if video else "File")).strip()
|
||||
channel = (job.display_uploader or asset.uploader or "").strip()
|
||||
# Pick the link-preview image. Facebook's scraper REJECTS our own poster ("Corrupted Image" in the
|
||||
# Sharing Debugger): the poster is an ffmpeg-produced JPEG whose non-standard marker order FB's
|
||||
@@ -118,7 +123,7 @@ def _watch_tags(token: str, db: Session) -> str | None:
|
||||
f"<title>{html.escape(title)}</title>",
|
||||
_tag("og:title", title),
|
||||
_tag("og:description", channel or "Shared via Siftlode"),
|
||||
_tag("og:type", "video.other"),
|
||||
_tag("og:type", "video.other" if video else "website"),
|
||||
_tag("og:url", url),
|
||||
_tag("og:site_name", "Siftlode"),
|
||||
_tag("twitter:title", title, attr="name"),
|
||||
|
||||
+10
-4
@@ -368,6 +368,10 @@ INDEX_HTML = STATIC_DIR / "index.html"
|
||||
# A public share link's own page: the player alone, without the app's bundle (frontend/watch.html).
|
||||
WATCH_HTML = STATIC_DIR / "watch.html"
|
||||
INDEX_HEADERS = {"Cache-Control": "no-cache"}
|
||||
# The paths a share link opens on, by what it opens (`links.share_path`). Any of them opens any link.
|
||||
# A new one also goes in frontend/public/robots.txt, the Vite dev entry and WatchPage's token parsing
|
||||
# (watchEntry.contract.test.ts).
|
||||
SHARE_PREFIXES = ("watch/", "file/", "folder/")
|
||||
app.mount(
|
||||
"/assets",
|
||||
StaticFiles(directory=STATIC_DIR / "assets", check_dir=False),
|
||||
@@ -381,8 +385,10 @@ async def index() -> FileResponse:
|
||||
|
||||
|
||||
@app.get("/watch/{token}")
|
||||
async def watch_page(token: str):
|
||||
"""Serve the public player page for a share link, with per-video Open Graph tags injected so the
|
||||
@app.get("/file/{token}")
|
||||
@app.get("/folder/{token}")
|
||||
async def share_page(token: str):
|
||||
"""Serve the public page of a share link, with per-link Open Graph tags injected so the
|
||||
link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser
|
||||
ignores the extra tags and boots WatchPage; invalid/expired/password links fall back to the
|
||||
generic card."""
|
||||
@@ -409,8 +415,8 @@ async def spa_fallback(full_path: str) -> FileResponse:
|
||||
# Real SPA-root assets (welcome images, favicon, robots.txt) rarely change and have
|
||||
# stable names, so a moderate cache is safe and satisfies the cache-lifetime audit.
|
||||
return FileResponse(candidate, headers={"Cache-Control": "public, max-age=604800"})
|
||||
# A /watch/ path the route above does not match (a trailing segment, no token) is still the
|
||||
# A share path the route above does not match (a trailing segment, no token) is still the
|
||||
# player's: WatchPage reads the token off the path and says when a link is gone.
|
||||
if full_path.startswith("watch/"):
|
||||
if full_path.startswith(SHARE_PREFIXES):
|
||||
return FileResponse(WATCH_HTML, headers=INDEX_HEADERS)
|
||||
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
|
||||
|
||||
@@ -611,7 +611,7 @@ def list_links(
|
||||
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> list[dict]:
|
||||
own_job(db, user, job_id)
|
||||
return [linksmod.owner_view(link) for link in queries.links_to(db, job_id=job_id)]
|
||||
return [linksmod.owner_view(db, link) for link in queries.links_to(db, job_id=job_id)]
|
||||
|
||||
|
||||
class CreateLinkIn(BaseModel):
|
||||
@@ -653,7 +653,7 @@ def create_link(
|
||||
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)
|
||||
return linksmod.owner_view(db, link, token=token)
|
||||
|
||||
|
||||
def _shareable_job(db: Session, user: User, job_id: int) -> "DownloadJob":
|
||||
@@ -684,7 +684,7 @@ def list_folder_links(
|
||||
folder_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> list[dict]:
|
||||
own_folder(db, user, folder_id)
|
||||
return [linksmod.owner_view(link) for link in queries.links_to(db, folder_id=folder_id)]
|
||||
return [linksmod.owner_view(db, link) for link in queries.links_to(db, folder_id=folder_id)]
|
||||
|
||||
|
||||
@router.post("/folders/{folder_id}/links")
|
||||
@@ -697,7 +697,7 @@ def create_folder_link(
|
||||
_shareable_folder(db, user, folder_id)
|
||||
link, token = _new_link(db, user, body, folder_id=folder_id)
|
||||
db.commit()
|
||||
return linksmod.owner_view(link, token=token)
|
||||
return linksmod.owner_view(db, link, token=token)
|
||||
|
||||
|
||||
class UpdateLinkIn(BaseModel):
|
||||
@@ -724,7 +724,7 @@ def update_link(
|
||||
sent=frozenset(body.model_fields_set),
|
||||
)
|
||||
db.commit()
|
||||
return linksmod.owner_view(link)
|
||||
return linksmod.owner_view(db, link)
|
||||
|
||||
|
||||
@router.delete("/links/{link_id}")
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""A share link's address says what it opens: /watch/ a video, /file/ anything else, /folder/ a folder.
|
||||
|
||||
Only a word for the reader: the token is the key, so every path opens every link, and the links
|
||||
already sent out on /watch/ keep working for good. DB-lane.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app import main, security
|
||||
from app.config import settings
|
||||
from app.downloads import og
|
||||
from app.downloads import probe as probemod
|
||||
|
||||
_PAGE = """<!doctype html><html><head>
|
||||
<!--OG:START--><meta property="og:title" content="Siftlode" /><!--OG:END-->
|
||||
</head><body><script src="/assets/{entry}.js"></script></body></html>"""
|
||||
|
||||
_VIDEO = {
|
||||
"streams": [
|
||||
{"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"},
|
||||
{"codec_type": "audio", "codec_name": "aac"},
|
||||
],
|
||||
"format": {"format_name": "mov,mp4", "duration": "5"},
|
||||
}
|
||||
_AUDIO = {
|
||||
"streams": [{"codec_type": "audio", "codec_name": "aac"}],
|
||||
"format": {"format_name": "mov,mp4", "duration": "5"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pages(tmp_path, monkeypatch):
|
||||
index, watch = tmp_path / "index.html", tmp_path / "watch.html"
|
||||
index.write_text(_PAGE.format(entry="app"), encoding="utf-8")
|
||||
watch.write_text(_PAGE.format(entry="watch"), encoding="utf-8")
|
||||
monkeypatch.setattr(main, "INDEX_HTML", index)
|
||||
monkeypatch.setattr(main, "WATCH_HTML", watch)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def key(monkeypatch):
|
||||
"""A link's address is read back from its encrypted token."""
|
||||
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shared(as_user, make_user, tmp_path, monkeypatch, upload_headers):
|
||||
"""Uploads a file ffprobe reads as `data` (None: it cannot read it) and shares it."""
|
||||
monkeypatch.setattr(settings, "files_root", str(tmp_path / "media"))
|
||||
owner = as_user(make_user())
|
||||
|
||||
def share(name: str, data: dict | None, **link: object) -> dict:
|
||||
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
|
||||
job = owner.post(
|
||||
"/api/files/upload",
|
||||
content=b"some bytes",
|
||||
headers=upload_headers(name, display_name=name),
|
||||
)
|
||||
assert job.status_code == 200, job.text
|
||||
made = owner.post(f"/api/downloads/{job.json()['id']}/links", json=link)
|
||||
assert made.status_code == 200, made.text
|
||||
return made.json()
|
||||
|
||||
share.owner = owner # type: ignore[attr-defined]
|
||||
return share
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "data", "prefix"),
|
||||
[
|
||||
("clip.mp4", _VIDEO, "/watch/"),
|
||||
("song.m4a", _AUDIO, "/file/"),
|
||||
("backup.tar.gz", None, "/file/"),
|
||||
],
|
||||
)
|
||||
def test_a_link_is_given_out_on_the_path_of_what_it_opens(shared, name, data, prefix):
|
||||
made = shared(name, data)
|
||||
assert made["url"] == f"{prefix}{made['token']}"
|
||||
|
||||
|
||||
def test_a_folder_link_is_given_out_on_folder(shared, key):
|
||||
owner = shared.owner
|
||||
folder = owner.post("/api/files/folders", json={"name": "Friends"}).json()
|
||||
made = owner.post(f"/api/downloads/folders/{folder['id']}/links", json={}).json()
|
||||
assert made["url"] == f"/folder/{made['token']}"
|
||||
listed = owner.get(f"/api/downloads/folders/{folder['id']}/links").json()
|
||||
assert [entry["url"] for entry in listed] == [made["url"]]
|
||||
|
||||
|
||||
def test_the_list_gives_the_same_address_as_the_making(shared, key):
|
||||
made = shared("backup.tar.gz", None)
|
||||
owner = shared.owner
|
||||
files = owner.get("/api/files").json()
|
||||
job_id = next(f["id"] for f in files if f["display_name"] == "backup.tar.gz")
|
||||
listed = owner.get(f"/api/downloads/{job_id}/links").json()
|
||||
assert [entry["url"] for entry in listed] == [made["url"]]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["watch", "file", "folder"])
|
||||
@pytest.mark.parametrize(
|
||||
("name", "data"), [("clip.mp4", _VIDEO), ("backup.tar.gz", None)], ids=["video", "file"]
|
||||
)
|
||||
def test_every_path_opens_every_link(pages, shared, client, path, name, data):
|
||||
"""The links already sent on /watch/ open whatever they open, for good."""
|
||||
token = shared(name, data)["token"]
|
||||
r = client.get(f"/{path}/{token}")
|
||||
assert r.status_code == 200
|
||||
assert "/assets/watch.js" in r.text and "/assets/app.js" not in r.text
|
||||
assert f'content="{name}"' in r.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/file/no-such-token", "/folder/no-such-token", "/file/a/b"])
|
||||
def test_a_new_path_without_a_live_link_still_gets_the_player(pages, client, path):
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
assert "/assets/watch.js" in r.text and "/assets/app.js" not in r.text
|
||||
|
||||
|
||||
def test_a_card_names_a_file_as_a_file(shared, db):
|
||||
made = shared("backup.tar.gz", None)
|
||||
tags = og._watch_tags(made["token"], db)
|
||||
assert f'{made["url"]}"' in tags
|
||||
assert "video.other" not in tags
|
||||
|
||||
locked = shared("notes.tar.gz", None, password="pw")
|
||||
hidden = og._watch_tags(locked["token"], db)
|
||||
assert "Password-protected file" in hidden and "notes" not in hidden
|
||||
assert "video.other" not in hidden
|
||||
|
||||
|
||||
def test_a_card_of_a_video_stays_a_video(shared, db):
|
||||
made = shared("clip.mp4", _VIDEO)
|
||||
tags = og._watch_tags(made["token"], db)
|
||||
assert f"/watch/{made['token']}" in tags and "video.other" in tags
|
||||
locked = shared("other.mp4", _VIDEO, password="pw")
|
||||
assert "Password-protected video" in og._watch_tags(locked["token"], db)
|
||||
@@ -1,14 +1,16 @@
|
||||
# Siftlode is mostly a private, login-gated app; only the public landing and legal
|
||||
# pages are worth indexing. Allow crawling but keep bots out of the API surface.
|
||||
# Share links (/watch/) are unguessable capability URLs: kept out of search indexes,
|
||||
# Share links (/watch/, /file/, /folder/) are unguessable capability URLs: kept out of search indexes,
|
||||
# but link-preview crawlers are allowed below so shared links unfurl in chat apps.
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /api/
|
||||
Disallow: /auth/
|
||||
Disallow: /watch/
|
||||
Disallow: /file/
|
||||
Disallow: /folder/
|
||||
|
||||
# Link-preview / unfurl bots — allowed to fetch /watch/ so shared links render a rich
|
||||
# Link-preview / unfurl bots — allowed to fetch share links so shared links render a rich
|
||||
# card (title/channel/thumbnail). They don't build a search index, so this doesn't
|
||||
# expose share links to search engines.
|
||||
User-agent: facebookexternalhit
|
||||
|
||||
@@ -145,7 +145,7 @@ export interface ShareLink {
|
||||
// Null when the server cannot show this link's address again (no encrypted copy its key reads): the
|
||||
// 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
|
||||
url: string | null; // path like "/watch/<token>" or "/file/<token>" — prepend window.location.origin to share
|
||||
allow_download: boolean;
|
||||
has_password: boolean;
|
||||
expires_at: string | null;
|
||||
|
||||
@@ -24,6 +24,20 @@ afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("WatchPage share paths", () => {
|
||||
it.each(["/watch/tok-p", "/file/tok-p", "/folder/tok-p", "/file/tok-p/extra"])(
|
||||
"reads the token off %s",
|
||||
async (path) => {
|
||||
window.history.replaceState({}, "", path);
|
||||
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
|
||||
render(<WatchPage />);
|
||||
await waitFor(() =>
|
||||
expect(fetch).toHaveBeenCalledWith("/api/public/watch/tok-p", expect.anything()),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("WatchPage background play", () => {
|
||||
it("hands its <video> to the background-play hook, so Brave iOS keeps it playing", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-bg");
|
||||
|
||||
@@ -28,7 +28,7 @@ import { SURFACE } from "../components/ui/surfaces";
|
||||
import { IconLink, LinkButton } from "../components/ui/LinkButton";
|
||||
import { T, lang } from "./watchStrings";
|
||||
|
||||
// Standalone, login-free page for a shared /watch/<token> link: one file, or a folder's list of them.
|
||||
// Standalone, login-free page for a share link (/watch/, /file/, /folder/): one file, or a folder's list.
|
||||
// Its own entry (watchMain.tsx, watch.html): no auth, no providers, none of the app's bundle. Uses
|
||||
// plain fetch and a tiny self-contained i18n dict (`watchStrings`: the public viewer has no app
|
||||
// language preference).
|
||||
@@ -83,8 +83,14 @@ async function notFoundState(r: Response): Promise<"preparing" | "gone"> {
|
||||
return body?.detail?.reason === "video_preparing" ? "preparing" : "gone";
|
||||
}
|
||||
|
||||
/** The token of a share path: /watch/ for a video, /file/ or /folder/ for the rest, any of them for
|
||||
* any link (`main.SHARE_PREFIXES`). */
|
||||
function shareToken(path: string): string {
|
||||
return /^\/(?:watch|file|folder)\/([^/?#]+)/.exec(path)?.[1] ?? "";
|
||||
}
|
||||
|
||||
export default function WatchPage() {
|
||||
const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? "";
|
||||
const token = shareToken(window.location.pathname);
|
||||
// Seeded from the URL rather than corrected by the effect below: with no token there is nothing
|
||||
// to load, and "loading" was committed and painted before the effect said so.
|
||||
const [status, setStatus] = useState<
|
||||
|
||||
@@ -99,6 +99,29 @@ function chain(seen: Map<string, string | null>, mod: string): string {
|
||||
return path.join(" → ");
|
||||
}
|
||||
|
||||
describe("the paths a share link opens on", () => {
|
||||
// The server's list: each of the places below that has to know them follows it.
|
||||
const main = readFileSync(join(SRC, "..", "..", "backend", "app", "main.py"), "utf8");
|
||||
const prefixes = [
|
||||
...(/SHARE_PREFIXES = \(([^)]*)\)/.exec(main)?.[1] ?? "").matchAll(/"(\w+)\/"/g),
|
||||
].map((m) => m[1]);
|
||||
|
||||
it("are read off the server", () => {
|
||||
expect(prefixes).toEqual(["watch", "file", "folder"]);
|
||||
});
|
||||
|
||||
it("are all kept out of search indexes", () => {
|
||||
const robots = readFileSync(join(SRC, "..", "public", "robots.txt"), "utf8");
|
||||
for (const p of prefixes) expect(robots).toContain(`Disallow: /${p}/`);
|
||||
});
|
||||
|
||||
it("all boot the player alone in `vite dev` and all give up their token", () => {
|
||||
const vite = readFileSync(join(SRC, "..", "vite.config.ts"), "utf8");
|
||||
const page = readFileSync(join(SRC, "watch", "WatchPage.tsx"), "utf8");
|
||||
for (const src of [vite, page]) expect(src).toContain(`(?:${prefixes.join("|")})`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the /watch entry", () => {
|
||||
it("loads nothing the app alone may load", () => {
|
||||
const seen = reach(join(SRC, "watchMain.tsx"));
|
||||
|
||||
@@ -32,18 +32,18 @@ const proxy = {
|
||||
"/healthz": proxyOpts,
|
||||
};
|
||||
|
||||
// Two HTML entries: the app (index.html) and a public /watch/<token> link (watch.html), which loads
|
||||
// Two HTML entries: the app (index.html) and a public share link (watch.html), which loads
|
||||
// only the player page. In a build the backend picks the file by path (`main.py`); this does the
|
||||
// same for `vite dev`, whose history fallback would otherwise hand /watch/… the app.
|
||||
const watchEntry = (): Plugin => ({
|
||||
name: "siftlode-watch-entry",
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
if (req.url?.startsWith("/watch/")) req.url = "/watch.html";
|
||||
if (req.url && /^\/(?:watch|file|folder)\//.test(req.url)) req.url = "/watch.html";
|
||||
next();
|
||||
});
|
||||
},
|
||||
// The server answers every /watch/ link with the built watch.html, so a build without it would
|
||||
// The server answers every share link with the built watch.html, so a build without it would
|
||||
// ship a 500 for each one, and nothing downstream reads `dist` before prod does. Fail it here —
|
||||
// after Vite's own HTML plugin has emitted the pages (`enforce: "post"`), or none is there yet.
|
||||
enforce: "post",
|
||||
|
||||
Reference in New Issue
Block a user