feat: add share link service, API routes, and short URL redirect

Create share.service.ts with token generation (128-bit base64url),
CRUD operations, validation, and visibility transition side effects.
Add share endpoints under /api/setups/:id/shares, shared access at
/api/shared/:token, and /s/:token short URL redirect.

Plan: 32-02 (Setup Sharing System - Share Link Backend)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 17:59:39 +02:00
parent 7a696f39a5
commit da159d10b8
6 changed files with 507 additions and 1 deletions

View File

@@ -26,6 +26,9 @@ import { setupRoutes } from "./routes/setups.ts";
import { tagRoutes } from "./routes/tags.ts";
import { threadRoutes } from "./routes/threads.ts";
import { totalRoutes } from "./routes/totals.ts";
import { getSetupWithItemsById } from "./services/setup.service.ts";
import { validateShareToken } from "./services/share.service.ts";
import { withImageUrls } from "./services/storage.service.ts";
// Seed default data on startup
await seedDefaults();
@@ -163,6 +166,27 @@ app.use("/api/users/:id/profile", async (c, next) => {
return next();
});
// Shared setup access via token (no auth required)
app.get("/api/shared/:token", async (c) => {
const db = c.get("db");
const token = c.req.param("token");
const result = await validateShareToken(db, token);
if (!result) return c.json({ error: "Not found" }, 404);
const setup = await getSetupWithItemsById(db, result.setupId);
if (!setup) return c.json({ error: "Not found" }, 404);
const enrichedItems = await withImageUrls(setup.items);
return c.json({ ...setup, items: enrichedItems });
});
// Short share URL redirect (no auth required — before SPA catch-all)
app.get("/s/:token", async (c) => {
const db = c.get("db");
const token = c.req.param("token");
const result = await validateShareToken(db, token);
if (!result) return c.redirect("/", 302);
return c.redirect(`/setups/${result.setupId}?share=${token}`, 302);
});
// Auth middleware for all data routes (userId must be available for per-user scoping)
app.use("/api/*", async (c, next) => {
// Skip auth routes — they handle their own auth
@@ -178,6 +202,9 @@ app.use("/api/*", async (c, next) => {
// Skip public tags endpoint (GET /api/tags)
if (c.req.path.startsWith("/api/tags") && c.req.method === "GET")
return next();
// Skip shared setup access (GET /api/shared/:token)
if (c.req.path.startsWith("/api/shared/") && c.req.method === "GET")
return next();
// Skip public discovery endpoints (GET /api/discovery/*)
if (c.req.path.startsWith("/api/discovery") && c.req.method === "GET")
return next();