| import express from "express"; |
| import { createServer } from "http"; |
| import { WebSocketServer } from "ws"; |
| import { Hocuspocus } from "@hocuspocus/server"; |
| import { Database } from "@hocuspocus/extension-database"; |
| import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; |
| import { join } from "path"; |
| import * as Y from "yjs"; |
| import { extractToken, isOAuthEnabled } from "./auth.js"; |
| import { isHfStorageEnabled, setUserToken, pullDocument } from "./hf-storage.js"; |
| import { citationsRouter } from "./citations.js"; |
| import { getDataDir, docPath, sanitizeName } from "./utils.js"; |
| import { debouncedSave, ensurePublishedRestored } from "./persistence.js"; |
| import { createAuthRouter, createRequireEditor } from "./routes/auth.js"; |
| import { createChatRouter } from "./routes/chat.js"; |
| import { createPublishRouter } from "./routes/publish.js"; |
| import { createUploadRouter } from "./routes/upload.js"; |
| import { createDatasetProxyRouter } from "./routes/dataset-proxy.js"; |
| import { createStorageRouter } from "./routes/storage.js"; |
|
|
| export { debouncedSave, resetSaveTimers, resetPublishedRestored } from "./persistence.js"; |
|
|
| const DEFAULT_DOC_NAME = "default"; |
|
|
| |
| |
| |
| |
| |
| |
| const HF_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 95 88" width="22" height="20" aria-hidden="true" focusable="false"> |
| <path fill="#FFD21E" d="M47.21 76.5a34.75 34.75 0 1 0 0-69.5 34.75 34.75 0 0 0 0 69.5Z"/> |
| <path fill="#FF9D0B" d="M81.96 41.75a34.75 34.75 0 1 0-69.5 0 34.75 34.75 0 0 0 69.5 0Zm-73.5 0a38.75 38.75 0 1 1 77.5 0 38.75 38.75 0 0 1-77.5 0Z"/> |
| <path fill="#3A3B45" d="M58.5 32.3c1.28.44 1.78 3.06 3.07 2.38a5 5 0 1 0-6.76-2.07c.61 1.15 2.55-.72 3.7-.32ZM34.95 32.3c-1.28.44-1.79 3.06-3.07 2.38a5 5 0 1 1 6.76-2.07c-.61 1.15-2.56-.72-3.7-.32Z"/> |
| <path fill="#FF323D" d="M46.96 56.29c9.83 0 13-8.76 13-13.26 0-2.34-1.57-1.6-4.09-.36-2.33 1.15-5.46 2.74-8.9 2.74-7.19 0-13-6.88-13-2.38s3.16 13.26 13 13.26Z"/> |
| <path fill="#3A3B45" fill-rule="evenodd" d="M39.43 54a8.7 8.7 0 0 1 5.3-4.49c.4-.12.81.57 1.24 1.28.4.68.82 1.37 1.24 1.37.45 0 .9-.68 1.33-1.35.45-.7.89-1.38 1.32-1.25a8.61 8.61 0 0 1 5 4.17c3.73-2.94 5.1-7.74 5.1-10.7 0-2.34-1.57-1.6-4.09-.36l-.14.07c-2.31 1.15-5.39 2.67-8.77 2.67s-6.45-1.52-8.77-2.67c-2.6-1.29-4.23-2.1-4.23.29 0 3.05 1.46 8.06 5.47 10.97Z" clip-rule="evenodd"/> |
| <path fill="#FF9D0B" d="M70.71 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM24.21 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5Z"/> |
| <path fill="#FF9D0B" d="M17.52 48c-1.62 0-3.06.66-4.07 1.87a5.97 5.97 0 0 0-1.33 3.76 7.1 7.1 0 0 0-1.94-.3c-1.55 0-2.95.59-3.94 1.66a5.8 5.8 0 0 0-.8 7 5.3 5.3 0 0 0-1.79 2.82c-.24.9-.48 2.8.8 4.74a5.22 5.22 0 0 0-.37 5.02c1.02 2.32 3.57 4.14 8.52 6.1 3.07 1.22 5.89 2 5.91 2.01a44.33 44.33 0 0 0 10.93 1.6c5.86 0 10.05-1.8 12.46-5.34 3.88-5.69 3.33-10.9-1.7-15.92-2.77-2.78-4.62-6.87-5-7.77-.78-2.66-2.84-5.62-6.25-5.62a5.7 5.7 0 0 0-4.6 2.46c-1-1.26-1.98-2.25-2.86-2.82A7.4 7.4 0 0 0 17.52 48Zm0 4c.51 0 1.14.22 1.82.65 2.14 1.36 6.25 8.43 7.76 11.18.5.92 1.37 1.31 2.14 1.31 1.55 0 2.75-1.53.15-3.48-3.92-2.93-2.55-7.72-.68-8.01.08-.02.17-.02.24-.02 1.7 0 2.45 2.93 2.45 2.93s2.2 5.52 5.98 9.3c3.77 3.77 3.97 6.8 1.22 10.83-1.88 2.75-5.47 3.58-9.16 3.58-3.81 0-7.73-.9-9.92-1.46-.11-.03-13.45-3.8-11.76-7 .28-.54.75-.76 1.34-.76 2.38 0 6.7 3.54 8.57 3.54.41 0 .7-.17.83-.6.79-2.85-12.06-4.05-10.98-8.17.2-.73.71-1.02 1.44-1.02 3.14 0 10.2 5.53 11.68 5.53.11 0 .2-.03.24-.1.74-1.2.33-2.04-4.9-5.2-5.21-3.16-8.88-5.06-6.8-7.33.24-.26.58-.38 1-.38 3.17 0 10.66 6.82 10.66 6.82s2.02 2.1 3.25 2.1c.28 0 .52-.1.68-.38.86-1.46-8.06-8.22-8.56-11.01-.34-1.9.24-2.85 1.31-2.85Z"/> |
| <path fill="#FFD21E" d="M38.6 76.69c2.75-4.04 2.55-7.07-1.22-10.84-3.78-3.77-5.98-9.3-5.98-9.3s-.82-3.2-2.69-2.9c-1.87.3-3.24 5.08.68 8.01 3.91 2.93-.78 4.92-2.29 2.17-1.5-2.75-5.62-9.82-7.76-11.18-2.13-1.35-3.63-.6-3.13 2.2.5 2.79 9.43 9.55 8.56 11-.87 1.47-3.93-1.71-3.93-1.71s-9.57-8.71-11.66-6.44c-2.08 2.27 1.59 4.17 6.8 7.33 5.23 3.16 5.64 4 4.9 5.2-.75 1.2-12.28-8.53-13.36-4.4-1.08 4.11 11.77 5.3 10.98 8.15-.8 2.85-9.06-5.38-10.74-2.18-1.7 3.21 11.65 6.98 11.76 7.01 4.3 1.12 15.25 3.49 19.08-2.12Z"/> |
| <path fill="#FF9D0B" d="M77.4 48c1.62 0 3.07.66 4.07 1.87a5.97 5.97 0 0 1 1.33 3.76 7.1 7.1 0 0 1 1.95-.3c1.55 0 2.95.59 3.94 1.66a5.8 5.8 0 0 1 .8 7 5.3 5.3 0 0 1 1.78 2.82c.24.9.48 2.8-.8 4.74a5.22 5.22 0 0 1 .37 5.02c-1.02 2.32-3.57 4.14-8.51 6.1-3.08 1.22-5.9 2-5.92 2.01a44.33 44.33 0 0 1-10.93 1.6c-5.86 0-10.05-1.8-12.46-5.34-3.88-5.69-3.33-10.9 1.7-15.92 2.78-2.78 4.63-6.87 5.01-7.77.78-2.66 2.83-5.62 6.24-5.62a5.7 5.7 0 0 1 4.6 2.46c1-1.26 1.98-2.25 2.87-2.82A7.4 7.4 0 0 1 77.4 48Zm0 4c-.51 0-1.13.22-1.82.65-2.13 1.36-6.25 8.43-7.76 11.18a2.43 2.43 0 0 1-2.14 1.31c-1.54 0-2.75-1.53-.14-3.48 3.91-2.93 2.54-7.72.67-8.01a1.54 1.54 0 0 0-.24-.02c-1.7 0-2.45 2.93-2.45 2.93s-2.2 5.52-5.97 9.3c-3.78 3.77-3.98 6.8-1.22 10.83 1.87 2.75 5.47 3.58 9.15 3.58 3.82 0 7.73-.9 9.93-1.46.1-.03 13.45-3.8 11.76-7-.29-.54-.75-.76-1.34-.76-2.38 0-6.71 3.54-8.57 3.54-.42 0-.71-.17-.83-.6-.8-2.85 12.05-4.05 10.97-8.17-.19-.73-.7-1.02-1.44-1.02-3.14 0-10.2 5.53-11.68 5.53-.1 0-.19-.03-.23-.1-.74-1.2-.34-2.04 4.88-5.2 5.23-3.16 8.9-5.06 6.8-7.33-.23-.26-.57-.38-.98-.38-3.18 0-10.67 6.82-10.67 6.82s-2.02 2.1-3.24 2.1a.74.74 0 0 1-.68-.38c-.87-1.46 8.05-8.22 8.55-11.01.34-1.9-.24-2.85-1.31-2.85Z"/> |
| <path fill="#FFD21E" d="M56.33 76.69c-2.75-4.04-2.56-7.07 1.22-10.84 3.77-3.77 5.97-9.3 5.97-9.3s.82-3.2 2.7-2.9c1.86.3 3.23 5.08-.68 8.01-3.92 2.93.78 4.92 2.28 2.17 1.51-2.75 5.63-9.82 7.76-11.18 2.13-1.35 3.64-.6 3.13 2.2-.5 2.79-9.42 9.55-8.55 11 .86 1.47 3.92-1.71 3.92-1.71s9.58-8.71 11.66-6.44c2.08 2.27-1.58 4.17-6.8 7.33-5.23 3.16-5.63 4-4.9 5.2.75 1.2 12.28-8.53 13.36-4.4 1.08 4.11-11.76 5.3-10.97 8.15.8 2.85 9.05-5.38 10.74-2.18 1.69 3.21-11.65 6.98-11.76 7.01-4.31 1.12-15.26 3.49-19.08-2.12Z"/> |
| </svg>`; |
|
|
| interface LoginPageOptions { |
| title: string; |
| description: string; |
| |
| |
| |
| |
| |
| |
| noteHtml?: string; |
| |
| buttonLabel?: string; |
| |
| buttonHref?: string; |
| } |
|
|
| function renderLoginPage({ |
| title, |
| description, |
| noteHtml, |
| buttonLabel, |
| buttonHref, |
| }: LoginPageOptions): string { |
| const safeTitle = escapeHtml(title); |
| const safeDescription = escapeHtml(description); |
| const safeButtonLabel = escapeHtml(buttonLabel || "Sign in with Hugging Face"); |
| const safeButtonHref = escapeHtml(buttonHref || "/oauth/authorize"); |
|
|
| return `<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| <title>${safeTitle} - Research Article Template Editor</title> |
| <style> |
| :root { |
| --primary-color: oklch(0.75 0.12 47); |
| --primary-color-hover: oklch(from var(--primary-color) calc(l - 0.06) c h); |
| --primary-color-active: oklch(from var(--primary-color) calc(l - 0.10) c h); |
| --page-bg: #fafaf9; |
| --text-color: #1a1a1a; |
| --muted-color: rgba(0, 0, 0, .58); |
| --font-stack: "Source Sans Pro", -apple-system, BlinkMacSystemFont, |
| "SF Pro Display", "SF Pro Text", "Segoe UI", system-ui, sans-serif; |
| } |
| @media (prefers-color-scheme: dark) { |
| :root { |
| --page-bg: #0c0d10; |
| --text-color: rgba(255, 255, 255, .92); |
| --muted-color: rgba(255, 255, 255, .58); |
| } |
| } |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| html, body { height: 100%; } |
| body { |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| padding: 32px; |
| font-family: var(--font-stack); |
| background: |
| radial-gradient( |
| ellipse 720px 520px at 50% -10%, |
| color-mix(in srgb, var(--primary-color) 18%, transparent), |
| transparent 65% |
| ), |
| radial-gradient( |
| ellipse 480px 360px at 100% 110%, |
| color-mix(in srgb, var(--primary-color) 9%, transparent), |
| transparent 70% |
| ), |
| var(--page-bg); |
| color: var(--text-color); |
| -webkit-font-smoothing: antialiased; |
| -moz-osx-font-smoothing: grayscale; |
| } |
| .login { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| text-align: center; |
| max-width: 480px; |
| width: 100%; |
| } |
| .login__title { |
| color: var(--text-color); |
| font-size: clamp(1.75rem, 1.4rem + 1.6vw, 2.25rem); |
| font-weight: 600; |
| letter-spacing: -0.025em; |
| line-height: 1.1; |
| margin-bottom: 14px; |
| } |
| .login__description { |
| max-width: 380px; |
| font-size: 1.0625rem; |
| line-height: 1.5; |
| color: var(--muted-color); |
| margin-bottom: 40px; |
| } |
| .login__btn { |
| display: inline-flex; |
| align-items: center; |
| gap: 10px; |
| padding: 14px 28px; |
| font-family: inherit; |
| font-size: 1rem; |
| font-weight: 600; |
| letter-spacing: -0.005em; |
| color: var(--text-color); |
| background: transparent; |
| border: 1.5px solid var(--primary-color); |
| border-radius: 999px; |
| text-decoration: none; |
| cursor: pointer; |
| transition: |
| transform 180ms cubic-bezier(.2, .9, .3, 1), |
| background-color 180ms ease, |
| border-color 180ms ease; |
| } |
| .login__btn:hover { |
| transform: translateY(-1px); |
| background: color-mix(in srgb, var(--primary-color) 10%, transparent); |
| border-color: var(--primary-color-hover); |
| } |
| .login__btn:active { |
| transform: translateY(0); |
| background: color-mix(in srgb, var(--primary-color) 16%, transparent); |
| } |
| .login__btn:focus-visible { |
| outline: 2px solid var(--primary-color); |
| outline-offset: 4px; |
| } |
| .login__btn svg { |
| flex-shrink: 0; |
| display: block; |
| } |
| .login__note { |
| max-width: 480px; |
| margin: 0 0 32px; |
| padding: 16px 20px; |
| text-align: left; |
| background: color-mix(in srgb, var(--primary-color) 8%, transparent); |
| border: 1px solid color-mix(in srgb, var(--primary-color) 30%, transparent); |
| border-radius: 12px; |
| color: var(--text-color); |
| font-size: 0.9375rem; |
| line-height: 1.55; |
| } |
| .login__note strong { font-weight: 600; } |
| .login__note code { |
| padding: 1px 6px; |
| background: color-mix(in srgb, var(--text-color) 8%, transparent); |
| border-radius: 4px; |
| font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; |
| font-size: 0.875em; |
| } |
| .login__note ol { |
| margin: 8px 0 0; |
| padding-left: 20px; |
| } |
| .login__note ol li + li { margin-top: 4px; } |
| </style> |
| </head> |
| <body> |
| <main class="login"> |
| <h1 class="login__title">${safeTitle}</h1> |
| <p class="login__description">${safeDescription}</p> |
| ${noteHtml ? `<div class="login__note">${noteHtml}</div>` : ""} |
| <a class="login__btn" href="${safeButtonHref}" target="_blank" rel="noopener"> |
| ${HF_LOGO_SVG} |
| <span>${safeButtonLabel}</span> |
| </a> |
| </main> |
| </body> |
| </html>`; |
| } |
|
|
| function escapeHtml(value: string): string { |
| return value |
| .replace(/&/g, "&") |
| .replace(/</g, "<") |
| .replace(/>/g, ">") |
| .replace(/"/g, """) |
| .replace(/'/g, "'"); |
| } |
|
|
| function sendLoginPage(res: express.Response) { |
| res.status(200).send( |
| renderLoginPage({ |
| title: "This article is not yet published", |
| description: "Sign in with your Hugging Face account to access the editor.", |
| }), |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function buildEditorLoginPage( |
| user: |
| | { name: string; accessIssue?: "no-org-grant" | "not-member"; spaceOrg?: string } |
| | null, |
| ): LoginPageOptions { |
| if (!user) { |
| return { |
| title: "Editor access", |
| description: "Sign in with your Hugging Face account to start editing.", |
| }; |
| } |
|
|
| const safeHandle = escapeHtml(user.name); |
| const safeOrg = user.spaceOrg ? escapeHtml(user.spaceOrg) : ""; |
|
|
| if (user.accessIssue === "no-org-grant" && safeOrg) { |
| return { |
| title: "Permission needed", |
| description: `You're signed in as @${safeHandle}, but this app can't see your membership of the ${safeOrg} organization.`, |
| noteHtml: [ |
| `<strong>To unlock editing</strong>, re-authorize the app and tick the <code>${safeOrg}</code> organization on the consent screen:`, |
| `<ol>`, |
| `<li>Click <em>Authorize again</em> below.</li>`, |
| `<li>On the Hugging Face consent screen, scroll to <em>Organizations</em>.</li>`, |
| `<li>Toggle access for <code>${safeOrg}</code> on, then confirm.</li>`, |
| `</ol>`, |
| ].join(""), |
| buttonLabel: "Authorize again", |
| buttonHref: "/oauth/authorize?prompt=consent", |
| }; |
| } |
|
|
| return { |
| title: "No write access", |
| description: |
| `You're signed in as @${safeHandle}, but your account doesn't have write access to this Space` + |
| (safeOrg ? ` (owned by ${safeOrg}).` : "."), |
| noteHtml: |
| `Ask an admin of <code>${safeOrg || "the Space"}</code> to add you as a write member. ` + |
| `If you have multiple Hugging Face accounts, you can also sign out and try again with the right one.`, |
| buttonLabel: "Sign in with another account", |
| buttonHref: "/oauth/authorize?prompt=consent", |
| }; |
| } |
|
|
| export function createApp() { |
| const DATA_DIR = getDataDir(); |
| mkdirSync(DATA_DIR, { recursive: true }); |
|
|
| const oauthEnabled = isOAuthEnabled(); |
|
|
| |
|
|
| const hocuspocus = new Hocuspocus({ |
| async onAuthenticate({ token, context }: { token: string; context: any }) { |
| if (!oauthEnabled) return; |
|
|
| const { resolveUser } = await import("./auth.js"); |
| |
| |
| |
| |
| const authToken = token || context?.token; |
|
|
| |
| |
| |
| |
| |
| const tokenSource = token |
| ? "client" |
| : context?.token |
| ? "cookie" |
| : "none"; |
| const tokenLen = authToken ? authToken.length : 0; |
|
|
| const user = await resolveUser(authToken); |
| const spaceOwner = (process.env.SPACE_ID || "").split("/")[0] || "(none)"; |
|
|
| if (!user) { |
| console.warn( |
| `[ws-auth] reject: no user resolved` + |
| ` source=${tokenSource} tokenLen=${tokenLen}` + |
| ` spaceOwner=${spaceOwner}`, |
| ); |
| throw new Error("Unauthorized: invalid or missing HF token"); |
| } |
| if (!user.canEdit) { |
| console.warn( |
| `[ws-auth] reject: ${user.name} can't write to ${spaceOwner}` + |
| ` issue=${user.accessIssue ?? "unknown"}` + |
| ` source=${tokenSource}`, |
| ); |
| throw new Error( |
| `Unauthorized: ${user.name} has no write access to ${spaceOwner}` + |
| (user.accessIssue ? ` (${user.accessIssue})` : ""), |
| ); |
| } |
|
|
| console.log( |
| `[ws-auth] accept user=${user.name} spaceOwner=${spaceOwner}` + |
| ` source=${tokenSource}`, |
| ); |
| if (authToken) setUserToken(authToken); |
| return { user }; |
| }, |
| extensions: [ |
| new Database({ |
| fetch: async ({ documentName }: { documentName: string }) => { |
| try { |
| const p = docPath(documentName); |
| if (existsSync(p)) { |
| const buf = readFileSync(p); |
| console.log(`[persist] fetch "${documentName}" from disk: ${buf.length} bytes`); |
| return buf; |
| } |
| console.log(`[persist] fetch "${documentName}": no file on disk`); |
|
|
| if (isHfStorageEnabled()) { |
| const data = await pullDocument(documentName); |
| if (data) { |
| writeFileSync(p, data); |
| console.log(`[persist] pulled ${documentName} from HF`); |
| return Buffer.from(data); |
| } |
| } |
| } catch (err) { |
| console.error(`[persist] fetch "${documentName}" failed:`, (err as Error).message); |
| } |
| return null; |
| }, |
| store: async () => {}, |
| }), |
| { |
| async onChange({ documentName, document }: { documentName: string; document: any }) { |
| console.log(`[persist] onChange "${documentName}"`); |
| debouncedSave(documentName, document); |
| }, |
| async afterLoadDocument({ documentName }: { documentName: string }) { |
| console.log(`[persist] loaded "${documentName}"`); |
| }, |
| } as any, |
| ], |
| }); |
|
|
| |
|
|
| const app = express(); |
| const httpServer = createServer(app); |
|
|
| app.use(express.json({ limit: "1mb" })); |
|
|
| const authCtx = { oauthEnabled }; |
| const requireEditor = createRequireEditor(authCtx); |
|
|
| app.use(createAuthRouter(authCtx)); |
| app.use(createChatRouter(requireEditor)); |
| app.use("/api/citations", citationsRouter); |
| app.use(createPublishRouter({ oauthEnabled, hocuspocus })); |
| app.use(createUploadRouter()); |
| app.use(createStorageRouter({ oauthEnabled })); |
| |
| |
| |
| app.use(createDatasetProxyRouter()); |
|
|
| |
|
|
| const wss = new WebSocketServer({ noServer: true }); |
|
|
| httpServer.on("upgrade", (req, socket, head) => { |
| const url = req.url || ""; |
| if (url === "/collab" || url.startsWith("/collab/") || url.startsWith("/collab?")) { |
| console.log(`[ws] upgrade request for ${url}`); |
| wss.handleUpgrade(req, socket, head, (ws) => { |
| ws.setMaxListeners(Infinity); |
| ws.on("error", (error) => { |
| console.error("[ws] socket error:", error.message); |
| }); |
| if (process.env.NODE_ENV !== "production") { |
| ws.on("message", (data: Buffer, isBinary: boolean) => { |
| const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as any); |
| console.log(`[ws-debug] msg ${buf.length}B binary=${isBinary} first20=${buf.slice(0, 20).toString("hex")}`); |
| }); |
| } |
| const token = extractToken(req.headers.cookie); |
| |
| |
| |
| |
| |
| |
| const cookieHeader = req.headers.cookie || ""; |
| console.log( |
| `[ws] upgrade cookies=${cookieHeader.length}B hasToken=${Boolean(token)}`, |
| ); |
| hocuspocus.handleConnection(ws, req, { token }); |
| }); |
| } else { |
| console.log(`[ws] rejected upgrade for ${url}`); |
| socket.destroy(); |
| } |
| }); |
|
|
| |
|
|
| app.use("/uploads", express.static(join(DATA_DIR, "uploads"))); |
| app.use("/published", express.static(join(DATA_DIR, "published"))); |
|
|
| const staticDir = |
| process.env.NODE_ENV === "production" |
| ? join(DATA_DIR, "..", "frontend-dist") |
| : join(DATA_DIR, "..", "..", "frontend", "dist"); |
|
|
| function getPublishedPath(docName: string): string { |
| return join(DATA_DIR, "published", docName, "index.html"); |
| } |
|
|
| function getPublishedAssetPath(docName: string, filename: string): string { |
| return join(DATA_DIR, "published", docName, filename); |
| } |
|
|
| if (existsSync(staticDir)) { |
| app.use(express.static(staticDir, { index: false })); |
|
|
| |
| |
| |
| |
| |
| |
|
|
| app.get("/llms.txt", async (_req, res) => { |
| const llmsPath = getPublishedAssetPath(DEFAULT_DOC_NAME, "llms.txt"); |
| if (!existsSync(llmsPath)) { |
| res.status(404).type("text/plain").send("Not yet published"); |
| return; |
| } |
| res.type("text/markdown; charset=utf-8"); |
| res.sendFile(llmsPath); |
| }); |
|
|
| app.get("/robots.txt", (_req, res) => { |
| res |
| .type("text/plain; charset=utf-8") |
| .send( |
| [ |
| "User-agent: *", |
| "Allow: /", |
| "", |
| "LLMs-Txt: /llms.txt", |
| "", |
| ].join("\n"), |
| ); |
| }); |
|
|
| app.get("/editor", async (req, res) => { |
| if (oauthEnabled) { |
| const { resolveUser } = await import("./auth.js"); |
| const token = extractToken(req.headers.cookie); |
| const user = await resolveUser(token); |
|
|
| if (!user || !user.canEdit) { |
| res.status(200).send(renderLoginPage(buildEditorLoginPage(user))); |
| return; |
| } |
| } |
|
|
| res.sendFile(join(staticDir, "index.html")); |
| }); |
|
|
| app.get("*", async (req, res) => { |
| if (!oauthEnabled) { |
| res.sendFile(join(staticDir, "index.html")); |
| return; |
| } |
|
|
| const visitorToken = extractToken(req.headers.cookie) ?? undefined; |
| await ensurePublishedRestored(visitorToken); |
|
|
| const publishedPath = getPublishedPath(DEFAULT_DOC_NAME); |
| if (existsSync(publishedPath)) { |
| res.sendFile(publishedPath); |
| return; |
| } |
|
|
| sendLoginPage(res); |
| }); |
| } |
|
|
| return { app, httpServer, hocuspocus, wss }; |
| } |
|
|