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:
2026-11-04 19:10:09 +01:00
parent 2bdf1ec76f
commit 4c09a22add
12 changed files with 233 additions and 26 deletions
+4 -2
View File
@@ -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
+1 -1
View File
@@ -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;
+14
View File
@@ -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");
+8 -2
View File
@@ -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<
+23
View File
@@ -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"));
+3 -3
View File
@@ -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",