Skip to content
File

Blob: src/worker/routes/sites.ts

typescript256 lines
1import { Hono, type Context } from "hono";
2import { and, eq, isNull, ne } from "drizzle-orm";
3 
4import type { AppContext } from "@/worker/app-context";
5import { pages, publishedPages, workspaceSites } from "@/worker/db/d1/schema";
6import { requireAuth } from "@/worker/middleware/auth";
7import { rateLimit } from "@/worker/middleware/rate-limit";
8import { checkMembership } from "@/worker/lib/membership";
9import { getPage } from "@/worker/lib/page-access";
10import { getSitesBaseDomain, isSitesFeatureEnabled, resolvePagePublishStatus } from "@/worker/lib/published-pages";
11import { buildSitePublicUrl } from "@/worker/lib/site-public-url";
12import {
13 bumpPublicSiteRevision,
14 updatePublicSiteSettingsWithRevision,
15 siteRevisionTimestamp,
16} from "@/worker/lib/site-invalidation";
17import { parseBody } from "@/worker/lib/validate";
18import { createLogger } from "@/worker/lib/logger";
19import { sitesSlug } from "@/shared/site-slug";
20import { WorkspaceSiteUpdateRequest } from "@/shared/types";
21import { getSitePublishingEntitlements, type ResolvedWorkspaceRole } from "@/shared/entitlements";
22 
23const log = createLogger("sites");
24 
25const sitesRouter = new Hono<AppContext>();
26 
27sitesRouter.use("*", async (c, next) => {
28 if (!isSitesFeatureEnabled(c.env)) {
29 return c.json({ error: "sites_disabled", message: "Sites are not enabled on this instance" }, 404);
30 }
31 await next();
32});
33 
34async function resolveSitesEntitlements(c: Context<AppContext>) {
35 const user = c.get("user")!;
36 const db = c.get("db");
37 const wid = c.req.param("wid")!;
38 const membership = await checkMembership(db, user.id, wid);
39 const role: ResolvedWorkspaceRole = membership?.role ?? "none";
40 const entitlements = getSitePublishingEntitlements(role);
41 return { user, db, wid, role, entitlements } as const;
42}
43 
44async function requireCanManageSite(c: Context<AppContext>) {
45 const access = await resolveSitesEntitlements(c);
46 if (!access.entitlements.manageSite) {
47 return {
48 error: c.json({ error: "forbidden", message: "Only workspace owners and admins can manage Sites" }, 403),
49 } as const;
50 }
51 return access;
52}
53 
54// GET /workspaces/:wid/site - site management config
55sitesRouter.get("/workspaces/:wid/site", requireAuth, rateLimit("RL_API"), async (c) => {
56 const guard = await requireCanManageSite(c);
57 if ("error" in guard) return guard.error;
58 const site = await c.get("db").select().from(workspaceSites).where(eq(workspaceSites.workspace_id, guard.wid)).get();
59 return c.json({ site: site ?? null, base_domain: getSitesBaseDomain(c.env) });
60});
61 
62// PATCH /workspaces/:wid/site - lazy upsert site config
63sitesRouter.patch("/workspaces/:wid/site", requireAuth, rateLimit("RL_API"), async (c) => {
64 const guard = await requireCanManageSite(c);
65 if ("error" in guard) return guard.error;
66 const { db, wid } = guard;
67 
68 const data = await parseBody(c, WorkspaceSiteUpdateRequest);
69 if (data instanceof Response) return data;
70 if (Object.keys(data).length === 0) {
71 return c.json({ error: "bad_request", message: "No fields to update" }, 400);
72 }
73 
74 if (data.slug !== undefined) {
75 const conflict = await db
76 .select({ wid: workspaceSites.workspace_id })
77 .from(workspaceSites)
78 .where(and(eq(workspaceSites.slug, data.slug), ne(workspaceSites.workspace_id, wid)))
79 .get();
80 if (conflict) {
81 return c.json({ error: "conflict", message: "This slug is already in use" }, 409);
82 }
83 }
84 
85 if (data.home_page_id !== undefined && data.home_page_id !== null) {
86 const status = await resolvePagePublishStatus(db, wid, data.home_page_id);
87 if (!status.page) {
88 return c.json({ error: "bad_request", message: "Home page not found" }, 400);
89 }
90 if (status.page.archived_at) {
91 return c.json({ error: "bad_request", message: "Home page is archived" }, 400);
92 }
93 if (status.page.kind !== "doc") {
94 return c.json({ error: "bad_request", message: "Home page must be a document" }, 400);
95 }
96 if (!status.is_explicit_root && !status.inherited_from) {
97 return c.json({ error: "bad_request", message: "Home page must be published first" }, 400);
98 }
99 }
100 
101 const existing = await db.select().from(workspaceSites).where(eq(workspaceSites.workspace_id, wid)).get();
102 const now = siteRevisionTimestamp();
103 
104 if (!existing) {
105 if (!data.slug) {
106 return c.json({ error: "bad_request", message: "Slug is required to create a site" }, 400);
107 }
108 await db.insert(workspaceSites).values({
109 workspace_id: wid,
110 slug: data.slug,
111 home_page_id: data.home_page_id ?? null,
112 published_at: data.published ? now : null,
113 created_at: now,
114 updated_at: now,
115 });
116 } else {
117 const updates: Record<string, unknown> = {};
118 if (data.slug !== undefined) updates.slug = data.slug;
119 if (data.home_page_id !== undefined) updates.home_page_id = data.home_page_id;
120 if (data.published !== undefined) {
121 // Preserve the original published_at when toggling enable->enable to avoid clock churn.
122 updates.published_at = data.published ? (existing.published_at ?? now) : null;
123 }
124 await updatePublicSiteSettingsWithRevision(db, wid, updates, now);
125 }
126 
127 const fresh = await db.select().from(workspaceSites).where(eq(workspaceSites.workspace_id, wid)).get();
128 log.info("site_updated", { wid, fields: Object.keys(data) });
129 return c.json({ site: fresh ?? null, base_domain: getSitesBaseDomain(c.env) });
130});
131 
132// GET /workspaces/:wid/site/slug-availability
133sitesRouter.get("/workspaces/:wid/site/slug-availability", requireAuth, rateLimit("RL_API"), async (c) => {
134 const guard = await requireCanManageSite(c);
135 if ("error" in guard) return guard.error;
136 const { db, wid } = guard;
137 
138 const slug = c.req.query("slug");
139 if (!slug) {
140 return c.json({ error: "bad_request", message: "slug query param is required" }, 400);
141 }
142 const validation = sitesSlug.safeParse(slug);
143 if (!validation.success) {
144 return c.json({ available: false, reason: validation.error.issues[0]?.message ?? "Invalid slug" });
145 }
146 const conflict = await db
147 .select({ wid: workspaceSites.workspace_id })
148 .from(workspaceSites)
149 .where(and(eq(workspaceSites.slug, slug), ne(workspaceSites.workspace_id, wid)))
150 .get();
151 return c.json({ available: !conflict, reason: conflict ? "This slug is already in use" : undefined });
152});
153 
154// GET /workspaces/:wid/site/pages - list explicit publish roots
155sitesRouter.get("/workspaces/:wid/site/pages", requireAuth, rateLimit("RL_API"), async (c) => {
156 const guard = await requireCanManageSite(c);
157 if ("error" in guard) return guard.error;
158 const { db, wid } = guard;
159 
160 const rows = await db
161 .select({
162 workspace_id: publishedPages.workspace_id,
163 page_id: publishedPages.page_id,
164 published_by: publishedPages.published_by,
165 published_at: publishedPages.published_at,
166 title: pages.title,
167 icon: pages.icon,
168 kind: pages.kind,
169 })
170 .from(publishedPages)
171 .innerJoin(pages, eq(publishedPages.page_id, pages.id))
172 .where(and(eq(publishedPages.workspace_id, wid), isNull(pages.archived_at)));
173 return c.json({ published_roots: rows });
174});
175 
176// POST /workspaces/:wid/site/pages/:id - mark page as publish root
177sitesRouter.post("/workspaces/:wid/site/pages/:id", requireAuth, rateLimit("RL_API"), async (c) => {
178 const guard = await requireCanManageSite(c);
179 if ("error" in guard) return guard.error;
180 const { db, wid, user } = guard;
181 const pageId = c.req.param("id");
182 
183 const page = await getPage(db, pageId, wid);
184 if (!page) {
185 return c.json({ error: "not_found", message: "Page not found" }, 404);
186 }
187 if (page.kind !== "doc") {
188 return c.json({ error: "bad_request", message: "Only document pages can be published" }, 400);
189 }
190 
191 await db
192 .insert(publishedPages)
193 .values({ workspace_id: wid, page_id: pageId, published_by: user.id })
194 .onConflictDoNothing();
195 await bumpPublicSiteRevision(db, wid);
196 log.info("page_published", { wid, pageId, userId: user.id });
197 return c.json({ ok: true });
198});
199 
200// DELETE /workspaces/:wid/site/pages/:id - remove explicit publish root
201sitesRouter.delete("/workspaces/:wid/site/pages/:id", requireAuth, rateLimit("RL_API"), async (c) => {
202 const guard = await requireCanManageSite(c);
203 if ("error" in guard) return guard.error;
204 const { db, wid, user } = guard;
205 const pageId = c.req.param("id");
206 
207 await db.delete(publishedPages).where(and(eq(publishedPages.workspace_id, wid), eq(publishedPages.page_id, pageId)));
208 await bumpPublicSiteRevision(db, wid);
209 log.info("page_unpublished", { wid, pageId, userId: user.id });
210 return c.json({ ok: true });
211});
212 
213// GET /workspaces/:wid/site/pages/:id/status - read-only publish status
214sitesRouter.get("/workspaces/:wid/site/pages/:id/status", requireAuth, rateLimit("RL_API"), async (c) => {
215 const access = await resolveSitesEntitlements(c);
216 const { db, wid } = access;
217 const pageId = c.req.param("id");
218 
219 if (!access.entitlements.viewPagePublishStatus) {
220 return c.json({ error: "forbidden", message: "You are not a member of this workspace" }, 403);
221 }
222 
223 const status = await resolvePagePublishStatus(db, wid, pageId);
224 if (!status.page) {
225 return c.json({ error: "not_found", message: "Page not found" }, 404);
226 }
227 
228 const published = status.is_explicit_root || Boolean(status.inherited_from);
229 
230 let publicUrl: string | null = null;
231 if (published && status.page.kind === "doc") {
232 const site = await db
233 .select({
234 slug: workspaceSites.slug,
235 home_page_id: workspaceSites.home_page_id,
236 published_at: workspaceSites.published_at,
237 })
238 .from(workspaceSites)
239 .where(eq(workspaceSites.workspace_id, wid))
240 .get();
241 if (site) {
242 publicUrl = buildSitePublicUrl(site, getSitesBaseDomain(c.env), pageId, status.page.title, new URL(c.req.url));
243 }
244 }
245 
246 return c.json({
247 published,
248 is_explicit_root: status.is_explicit_root,
249 inherited_from: status.inherited_from,
250 public_url: publicUrl,
251 canvas: status.page.kind === "canvas",
252 });
253});
254 
255export { sitesRouter };