this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Formatting

+858 -858
-2
bun.lock
··· 21 21 "sitebase": "./src/index.ts", 22 22 }, 23 23 "dependencies": { 24 - "@sitebase/cli": ".", 25 - "@sitebase/cli": "workspace:*", 26 24 "@sitebase/core": "workspace:*", 27 25 "commander": "^12.1.0", 28 26 },
+13 -13
packages/cli/package.json
··· 1 1 { 2 - "name": "@sitebase/cli", 3 - "type": "module", 4 - "version": "0.0.1", 5 - "bin": { 6 - "sitebase": "./src/index.ts" 7 - }, 8 - "scripts": { 9 - "typecheck": "tsc --noEmit" 10 - }, 11 - "dependencies": { 12 - "@sitebase/core": "workspace:*", 13 - "commander": "^12.1.0" 14 - } 2 + "name": "@sitebase/cli", 3 + "type": "module", 4 + "version": "0.0.1", 5 + "bin": { 6 + "sitebase": "./src/index.ts" 7 + }, 8 + "scripts": { 9 + "typecheck": "tsc --noEmit" 10 + }, 11 + "dependencies": { 12 + "@sitebase/core": "workspace:*", 13 + "commander": "^12.1.0" 14 + } 15 15 }
+62 -82
packages/cli/src/index.ts
··· 1 1 #!/usr/bin/env bun 2 - import { readFile } from "node:fs/promises"; 3 - import { resolve } from "node:path"; 2 + import { dirname, resolve } from "node:path"; 4 3 import { Command } from "commander"; 5 - import { DEFAULT_FILENAME_TEMPLATE, exportPublication } from "@sitebase/core"; 4 + import { 5 + exportFromConfig, 6 + findConfigFile, 7 + loadExportConfig, 8 + } from "@sitebase/core"; 6 9 7 10 const program = new Command(); 8 11 9 12 program 10 - .name("sitebase") 11 - .description("CLI tools for standard.site publications") 12 - .version("0.0.1"); 13 + .name("sitebase") 14 + .description("CLI tools for standard.site publications") 15 + .version("0.0.1"); 13 16 14 17 program 15 - .command("export") 16 - .description("Export a publication to markdown files") 17 - .argument("<at-uri>", "AT URI of the publication to export") 18 - .requiredOption("-o, --output <dir>", "Output directory for markdown files") 19 - .option("-t, --template <file>", "Path to custom content template file") 20 - .option("--filename-template <template>", "Handlebars template for filenames") 21 - .option( 22 - "--include-tags <tags>", 23 - "Only include documents with these tags (comma-separated)", 24 - ) 25 - .option( 26 - "--exclude-tags <tags>", 27 - "Exclude documents with these tags (comma-separated)", 28 - ) 29 - .action( 30 - async ( 31 - atUri: string, 32 - options: { 33 - output: string; 34 - template?: string; 35 - filenameTemplate?: string; 36 - includeTags?: string; 37 - excludeTags?: string; 38 - }, 39 - ) => { 40 - try { 41 - // Parse tag options 42 - const includeTags = options.includeTags 43 - ? options.includeTags.split(",").map((t) => t.trim()) 44 - : undefined; 45 - const excludeTags = options.excludeTags 46 - ? options.excludeTags.split(",").map((t) => t.trim()) 47 - : undefined; 18 + .command("export") 19 + .description("Export a publication to markdown files") 20 + .option( 21 + "-c, --config <file>", 22 + "Path to config file (auto-discovers sitebase.config.{ts,js} if not specified)", 23 + ) 24 + .action(async (options: { config?: string }) => { 25 + try { 26 + // Find config file 27 + let configPath = options.config ? resolve(options.config) : null; 48 28 49 - // Load custom content template if provided 50 - let contentTemplate: string | undefined; 51 - if (options.template) { 52 - const templatePath = resolve(options.template); 53 - contentTemplate = await readFile(templatePath, "utf-8"); 54 - } 29 + if (!configPath) { 30 + // Auto-discover 31 + configPath = await findConfigFile(process.cwd()); 32 + if (!configPath) { 33 + console.error( 34 + "No config file found. Create sitebase.config.ts or specify with --config", 35 + ); 36 + process.exit(1); 37 + } 38 + } 55 39 56 - // Use custom filename template or default 57 - const filenameTemplate = 58 - options.filenameTemplate || DEFAULT_FILENAME_TEMPLATE; 40 + console.log(`Using config: ${configPath}`); 41 + const config = await loadExportConfig(configPath); 59 42 60 - console.log(`Exporting publication: ${atUri}`); 61 - console.log(`Output directory: ${options.output}`); 43 + console.log(`Exporting publication: ${config.publicationUri}`); 44 + console.log(`Export targets: ${config.exports.length}`); 62 45 63 - const result = await exportPublication({ 64 - publicationUri: atUri, 65 - outputDir: resolve(options.output), 66 - contentTemplate, 67 - filenameTemplate, 68 - includeTags, 69 - excludeTags, 70 - }); 46 + const configDir = dirname(configPath); 47 + const exportResults = await exportFromConfig(config, configDir); 71 48 72 - console.log(`\nExport complete:`); 73 - console.log(` Documents processed: ${result.documentsProcessed}`); 74 - console.log(` Documents skipped: ${result.documentsSkipped}`); 75 - console.log(` Files written: ${result.filesWritten.length}`); 49 + // Print results for each target 50 + for (const [i, result] of exportResults.entries()) { 51 + const target = config.exports[i]; 52 + console.log(`\nTarget ${i + 1}: ${target?.outputDir}`); 53 + console.log(` Documents processed: ${result.documentsProcessed}`); 54 + console.log(` Documents skipped: ${result.documentsSkipped}`); 55 + console.log(` Files written: ${result.filesWritten.length}`); 76 56 77 - if (result.warnings.length > 0) { 78 - console.log(`\nWarnings:`); 79 - for (const warning of result.warnings) { 80 - console.log(` - ${warning}`); 81 - } 82 - } 57 + if (result.warnings.length > 0) { 58 + console.log(` Warnings:`); 59 + for (const warning of result.warnings) { 60 + console.log(` - ${warning}`); 61 + } 62 + } 83 63 84 - if (result.filesWritten.length > 0) { 85 - console.log(`\nFiles:`); 86 - for (const file of result.filesWritten) { 87 - console.log(` - ${file}`); 88 - } 89 - } 90 - } catch (error) { 91 - console.error( 92 - `Error: ${error instanceof Error ? error.message : String(error)}`, 93 - ); 94 - process.exit(1); 95 - } 96 - }, 97 - ); 64 + if (result.filesWritten.length > 0) { 65 + console.log(` Files:`); 66 + for (const file of result.filesWritten) { 67 + console.log(` - ${file}`); 68 + } 69 + } 70 + } 71 + } catch (error) { 72 + console.error( 73 + `Error: ${error instanceof Error ? error.message : String(error)}`, 74 + ); 75 + process.exit(1); 76 + } 77 + }); 98 78 99 79 program.parse();
+22 -22
packages/web/package.json
··· 1 1 { 2 - "name": "@sitebase/web", 3 - "module": "src/server.ts", 4 - "type": "module", 5 - "private": true, 6 - "scripts": { 7 - "dev": "bun --hot run src/server.ts", 8 - "start": "bun run src/server.ts", 9 - "typecheck": "tsc --noEmit" 10 - }, 11 - "devDependencies": { 12 - "@types/bun": "latest" 13 - }, 14 - "peerDependencies": { 15 - "typescript": "^5" 16 - }, 17 - "dependencies": { 18 - "@atproto/api": "^0.18.13", 19 - "@atproto/jwk-jose": "^0.1.11", 20 - "@atproto/oauth-client-node": "^0.3.15", 21 - "hono": "^4.11.3", 22 - "marked": "^15.0.0" 23 - } 2 + "name": "@sitebase/web", 3 + "module": "src/server.ts", 4 + "type": "module", 5 + "private": true, 6 + "scripts": { 7 + "dev": "bun --hot run src/server.ts", 8 + "start": "bun run src/server.ts", 9 + "typecheck": "tsc --noEmit" 10 + }, 11 + "devDependencies": { 12 + "@types/bun": "latest" 13 + }, 14 + "peerDependencies": { 15 + "typescript": "^5" 16 + }, 17 + "dependencies": { 18 + "@atproto/api": "^0.18.13", 19 + "@atproto/jwk-jose": "^0.1.11", 20 + "@atproto/oauth-client-node": "^0.3.15", 21 + "hono": "^4.11.3", 22 + "marked": "^15.0.0" 23 + } 24 24 }
+19 -19
packages/web/scripts/cleanup.ts
··· 6 6 */ 7 7 8 8 import { Database } from "bun:sqlite"; 9 - import * as path from "path"; 9 + import * as path from "node:path"; 10 10 11 11 const DATA_DIR = process.env.DATA_DIR || "./data"; 12 12 const DB_PATH = path.join(DATA_DIR, "oauth.db"); 13 13 14 14 try { 15 - const db = new Database(DB_PATH); 15 + const db = new Database(DB_PATH); 16 16 17 - // Clean up OAuth states older than 1 hour 18 - const statesResult = db.run( 19 - `DELETE FROM oauth_states WHERE created_at < strftime('%s', 'now') - 3600`, 20 - ); 17 + // Clean up OAuth states older than 1 hour 18 + const statesResult = db.run( 19 + `DELETE FROM oauth_states WHERE created_at < strftime('%s', 'now') - 3600`, 20 + ); 21 21 22 - // Clean up sessions older than 30 days (optional - sessions may still be valid) 23 - const sessionsResult = db.run( 24 - `DELETE FROM oauth_sessions WHERE updated_at < strftime('%s', 'now') - 2592000`, 25 - ); 22 + // Clean up sessions older than 30 days (optional - sessions may still be valid) 23 + const sessionsResult = db.run( 24 + `DELETE FROM oauth_sessions WHERE updated_at < strftime('%s', 'now') - 2592000`, 25 + ); 26 26 27 - // Vacuum the database to reclaim space 28 - db.run("VACUUM"); 27 + // Vacuum the database to reclaim space 28 + db.run("VACUUM"); 29 29 30 - const timestamp = new Date().toISOString(); 31 - console.log( 32 - `[${timestamp}] Cleanup complete: removed old states and sessions, vacuumed database`, 33 - ); 30 + const timestamp = new Date().toISOString(); 31 + console.log( 32 + `[${timestamp}] Cleanup complete: removed old states and sessions, vacuumed database`, 33 + ); 34 34 35 - db.close(); 35 + db.close(); 36 36 } catch (error) { 37 - console.error("Cleanup failed:", error); 38 - process.exit(1); 37 + console.error("Cleanup failed:", error); 38 + process.exit(1); 39 39 }
+2 -2
packages/web/src/lib/logger.ts
··· 1 - import * as fs from "fs"; 2 - import * as path from "path"; 1 + import * as fs from "node:fs"; 2 + import * as path from "node:path"; 3 3 4 4 const DATA_DIR = process.env.DATA_DIR || "./data"; 5 5 const LOG_PATH = path.join(DATA_DIR, "app.log");
+96 -96
packages/web/src/lib/oauth.ts
··· 1 1 import { NodeOAuthClient } from "@atproto/oauth-client-node"; 2 2 import type { 3 - NodeSavedSession, 4 - NodeSavedState, 3 + NodeSavedSession, 4 + NodeSavedState, 5 5 } from "@atproto/oauth-client-node"; 6 6 import { JoseKey } from "@atproto/jwk-jose"; 7 7 import { Agent } from "@atproto/api"; 8 8 import { Database } from "bun:sqlite"; 9 - import * as fs from "fs"; 10 - import * as path from "path"; 9 + import * as fs from "node:fs"; 10 + import * as path from "node:path"; 11 11 12 12 // Constants 13 13 const PUBLIC_URL = process.env.PUBLIC_URL || "http://localhost:8000"; ··· 17 17 18 18 // Ensure data directory exists 19 19 if (!fs.existsSync(DATA_DIR)) { 20 - fs.mkdirSync(DATA_DIR, { recursive: true }); 20 + fs.mkdirSync(DATA_DIR, { recursive: true }); 21 21 } 22 22 23 23 // Initialize SQLite database ··· 42 42 43 43 // Clean up old states (older than 1 hour) 44 44 db.run( 45 - `DELETE FROM oauth_states WHERE created_at < strftime('%s', 'now') - 3600`, 45 + `DELETE FROM oauth_states WHERE created_at < strftime('%s', 'now') - 3600`, 46 46 ); 47 47 48 48 // State store implementation 49 49 const stateStore = { 50 - async set(key: string, state: NodeSavedState): Promise<void> { 51 - const stateJson = JSON.stringify(state); 52 - db.run( 53 - `INSERT OR REPLACE INTO oauth_states (key, state, created_at) VALUES (?, ?, strftime('%s', 'now'))`, 54 - [key, stateJson], 55 - ); 56 - }, 57 - async get(key: string): Promise<NodeSavedState | undefined> { 58 - const row = db 59 - .query(`SELECT state FROM oauth_states WHERE key = ?`) 60 - .get(key) as { state: string } | null; 61 - if (!row) return undefined; 62 - return JSON.parse(row.state); 63 - }, 64 - async del(key: string): Promise<void> { 65 - db.run(`DELETE FROM oauth_states WHERE key = ?`, [key]); 66 - }, 50 + async set(key: string, state: NodeSavedState): Promise<void> { 51 + const stateJson = JSON.stringify(state); 52 + db.run( 53 + `INSERT OR REPLACE INTO oauth_states (key, state, created_at) VALUES (?, ?, strftime('%s', 'now'))`, 54 + [key, stateJson], 55 + ); 56 + }, 57 + async get(key: string): Promise<NodeSavedState | undefined> { 58 + const row = db 59 + .query(`SELECT state FROM oauth_states WHERE key = ?`) 60 + .get(key) as { state: string } | null; 61 + if (!row) return undefined; 62 + return JSON.parse(row.state); 63 + }, 64 + async del(key: string): Promise<void> { 65 + db.run(`DELETE FROM oauth_states WHERE key = ?`, [key]); 66 + }, 67 67 }; 68 68 69 69 // Session store implementation 70 70 const sessionStore = { 71 - async set(did: string, session: NodeSavedSession): Promise<void> { 72 - const sessionJson = JSON.stringify(session); 73 - db.run( 74 - `INSERT OR REPLACE INTO oauth_sessions (did, session, updated_at) VALUES (?, ?, strftime('%s', 'now'))`, 75 - [did, sessionJson], 76 - ); 77 - }, 78 - async get(did: string): Promise<NodeSavedSession | undefined> { 79 - const row = db 80 - .query(`SELECT session FROM oauth_sessions WHERE did = ?`) 81 - .get(did) as { session: string } | null; 82 - if (!row) return undefined; 83 - return JSON.parse(row.session); 84 - }, 85 - async del(did: string): Promise<void> { 86 - db.run(`DELETE FROM oauth_sessions WHERE did = ?`, [did]); 87 - }, 71 + async set(did: string, session: NodeSavedSession): Promise<void> { 72 + const sessionJson = JSON.stringify(session); 73 + db.run( 74 + `INSERT OR REPLACE INTO oauth_sessions (did, session, updated_at) VALUES (?, ?, strftime('%s', 'now'))`, 75 + [did, sessionJson], 76 + ); 77 + }, 78 + async get(did: string): Promise<NodeSavedSession | undefined> { 79 + const row = db 80 + .query(`SELECT session FROM oauth_sessions WHERE did = ?`) 81 + .get(did) as { session: string } | null; 82 + if (!row) return undefined; 83 + return JSON.parse(row.session); 84 + }, 85 + async del(did: string): Promise<void> { 86 + db.run(`DELETE FROM oauth_sessions WHERE did = ?`, [did]); 87 + }, 88 88 }; 89 89 90 90 // Generate or load private key for confidential client 91 91 async function getOrCreatePrivateKey(): Promise<JoseKey> { 92 - if (fs.existsSync(KEYS_PATH)) { 93 - const keyData = JSON.parse(fs.readFileSync(KEYS_PATH, "utf-8")); 94 - return JoseKey.fromJWK(keyData, keyData.kid); 95 - } 92 + if (fs.existsSync(KEYS_PATH)) { 93 + const keyData = JSON.parse(fs.readFileSync(KEYS_PATH, "utf-8")); 94 + return JoseKey.fromJWK(keyData, keyData.kid); 95 + } 96 96 97 - // Generate a new ES256 key 98 - const key = await JoseKey.generate(["ES256"], crypto.randomUUID()); 99 - const jwk = key.privateJwk; 97 + // Generate a new ES256 key 98 + const key = await JoseKey.generate(["ES256"], crypto.randomUUID()); 99 + const jwk = key.privateJwk; 100 100 101 - // Save to disk with restrictive permissions (owner read/write only) 102 - fs.writeFileSync(KEYS_PATH, JSON.stringify(jwk, null, 2), { mode: 0o600 }); 101 + // Save to disk with restrictive permissions (owner read/write only) 102 + fs.writeFileSync(KEYS_PATH, JSON.stringify(jwk, null, 2), { mode: 0o600 }); 103 103 104 - return key; 104 + return key; 105 105 } 106 106 107 107 let oauthClientInstance: NodeOAuthClient | null = null; 108 108 let initPromise: Promise<NodeOAuthClient> | null = null; 109 109 110 110 async function initOAuthClient(): Promise<NodeOAuthClient> { 111 - if (oauthClientInstance) return oauthClientInstance; 112 - if (initPromise) return initPromise; 111 + if (oauthClientInstance) return oauthClientInstance; 112 + if (initPromise) return initPromise; 113 113 114 - initPromise = (async () => { 115 - const privateKey = await getOrCreatePrivateKey(); 114 + initPromise = (async () => { 115 + const privateKey = await getOrCreatePrivateKey(); 116 116 117 - oauthClientInstance = new NodeOAuthClient({ 118 - clientMetadata: { 119 - client_id: `${PUBLIC_URL}/client-metadata.json`, 120 - client_name: "sitebase", 121 - client_uri: PUBLIC_URL, 122 - redirect_uris: [`${PUBLIC_URL}/auth/callback`], 123 - scope: "atproto transition:generic", 124 - grant_types: ["authorization_code", "refresh_token"], 125 - response_types: ["code"], 126 - application_type: "web", 127 - token_endpoint_auth_method: "private_key_jwt", 128 - token_endpoint_auth_signing_alg: "ES256", 129 - dpop_bound_access_tokens: true, 130 - jwks_uri: `${PUBLIC_URL}/jwks.json`, 131 - }, 132 - keyset: [privateKey], 133 - stateStore, 134 - sessionStore, 135 - }); 117 + oauthClientInstance = new NodeOAuthClient({ 118 + clientMetadata: { 119 + client_id: `${PUBLIC_URL}/client-metadata.json`, 120 + client_name: "sitebase", 121 + client_uri: PUBLIC_URL, 122 + redirect_uris: [`${PUBLIC_URL}/auth/callback`], 123 + scope: "atproto transition:generic", 124 + grant_types: ["authorization_code", "refresh_token"], 125 + response_types: ["code"], 126 + application_type: "web", 127 + token_endpoint_auth_method: "private_key_jwt", 128 + token_endpoint_auth_signing_alg: "ES256", 129 + dpop_bound_access_tokens: true, 130 + jwks_uri: `${PUBLIC_URL}/jwks.json`, 131 + }, 132 + keyset: [privateKey], 133 + stateStore, 134 + sessionStore, 135 + }); 136 136 137 - return oauthClientInstance; 138 - })(); 137 + return oauthClientInstance; 138 + })(); 139 139 140 - return initPromise; 140 + return initPromise; 141 141 } 142 142 143 143 export async function getOAuthClient(): Promise<NodeOAuthClient> { 144 - return initOAuthClient(); 144 + return initOAuthClient(); 145 145 } 146 146 147 147 export async function getClientMetadata() { 148 - const client = await getOAuthClient(); 149 - return client.clientMetadata; 148 + const client = await getOAuthClient(); 149 + return client.clientMetadata; 150 150 } 151 151 152 152 export async function getJwks() { 153 - const client = await getOAuthClient(); 154 - return client.jwks; 153 + const client = await getOAuthClient(); 154 + return client.jwks; 155 155 } 156 156 157 157 export async function getAgentForSession( 158 - did: string, 158 + did: string, 159 159 ): Promise<{ agent: Agent; did: string; handle: string }> { 160 - const client = await getOAuthClient(); 161 - const oauthSession = await client.restore(did); 160 + const client = await getOAuthClient(); 161 + const oauthSession = await client.restore(did); 162 162 163 - if (!oauthSession) { 164 - throw new Error("Session not found"); 165 - } 163 + if (!oauthSession) { 164 + throw new Error("Session not found"); 165 + } 166 166 167 - const agent = new Agent(oauthSession); 167 + const agent = new Agent(oauthSession); 168 168 169 - // Fetch profile to get handle 170 - const profile = await agent.getProfile({ actor: did }); 169 + // Fetch profile to get handle 170 + const profile = await agent.getProfile({ actor: did }); 171 171 172 - return { 173 - agent, 174 - did, 175 - handle: profile.data.handle, 176 - }; 172 + return { 173 + agent, 174 + did, 175 + handle: profile.data.handle, 176 + }; 177 177 } 178 178 179 179 export async function deleteSession(did: string): Promise<void> { 180 - await sessionStore.del(did); 180 + await sessionStore.del(did); 181 181 }
+95 -91
packages/web/src/routes/auth.ts
··· 2 2 import { getCookie, setCookie, deleteCookie } from "hono/cookie"; 3 3 import { html } from "hono/html"; 4 4 import { 5 - getOAuthClient, 6 - getClientMetadata, 7 - getJwks, 8 - deleteSession, 5 + getOAuthClient, 6 + getClientMetadata, 7 + getJwks, 8 + deleteSession, 9 9 } from "../lib/oauth"; 10 10 import { layout } from "../views/layouts/main"; 11 11 import { csrfField } from "../lib/csrf"; ··· 15 15 16 16 // Client metadata endpoint (required for OAuth) 17 17 authRoutes.get("/client-metadata.json", async (c) => { 18 - try { 19 - const metadata = await getClientMetadata(); 20 - return c.json(metadata); 21 - } catch (error) { 22 - console.error("Error getting client metadata:", error); 23 - return c.json({ error: "Failed to get client metadata" }, 500); 24 - } 18 + try { 19 + const metadata = await getClientMetadata(); 20 + return c.json(metadata); 21 + } catch (error) { 22 + console.error("Error getting client metadata:", error); 23 + return c.json({ error: "Failed to get client metadata" }, 500); 24 + } 25 25 }); 26 26 27 27 // JWKS endpoint (required for confidential clients) 28 28 authRoutes.get("/jwks.json", async (c) => { 29 - try { 30 - const jwks = await getJwks(); 31 - return c.json(jwks); 32 - } catch (error) { 33 - console.error("Error getting JWKS:", error); 34 - return c.json({ error: "Failed to get JWKS" }, 500); 35 - } 29 + try { 30 + const jwks = await getJwks(); 31 + return c.json(jwks); 32 + } catch (error) { 33 + console.error("Error getting JWKS:", error); 34 + return c.json({ error: "Failed to get JWKS" }, 500); 35 + } 36 36 }); 37 37 38 38 // Login page 39 39 authRoutes.get("/login", async (c) => { 40 - const error = c.req.query("error"); 41 - const csrfToken = c.get("csrfToken") as string; 40 + const error = c.req.query("error"); 41 + const csrfToken = c.get("csrfToken") as string; 42 42 43 - const content = html` 43 + const content = html` 44 44 <div class="auth-form"> 45 45 <h1>Login with Bluesky</h1> 46 46 47 - ${error 48 - ? html` 47 + ${ 48 + error 49 + ? html` 49 50 <div class="error-message"> 50 - ${error === "handle_required" 51 - ? "Please enter your handle or DID." 52 - : error === "authorization_failed" 53 - ? "Authorization failed. Please try again." 54 - : error === "callback_failed" 55 - ? "Login failed. Please try again." 56 - : "An error occurred. Please try again."} 51 + ${ 52 + error === "handle_required" 53 + ? "Please enter your handle or DID." 54 + : error === "authorization_failed" 55 + ? "Authorization failed. Please try again." 56 + : error === "callback_failed" 57 + ? "Login failed. Please try again." 58 + : "An error occurred. Please try again." 59 + } 57 60 </div> 58 61 ` 59 - : ""} 62 + : "" 63 + } 60 64 61 65 <form action="/auth/login" method="POST"> 62 66 ${csrfField(csrfToken)} ··· 81 85 </div> 82 86 `; 83 87 84 - return c.html(layout(content, { title: "Login - sitebase" })); 88 + return c.html(layout(content, { title: "Login - sitebase" })); 85 89 }); 86 90 87 91 // Handle login form submission 88 92 authRoutes.post("/login", async (c) => { 89 - const body = await c.req.parseBody(); 90 - let handle = body.handle as string; 93 + const body = await c.req.parseBody(); 94 + let handle = body.handle as string; 91 95 92 - if (!handle) { 93 - return c.redirect("/auth/login?error=handle_required"); 94 - } 96 + if (!handle) { 97 + return c.redirect("/auth/login?error=handle_required"); 98 + } 95 99 96 - // Trim and normalize handle 97 - handle = handle.trim().toLowerCase(); 100 + // Trim and normalize handle 101 + handle = handle.trim().toLowerCase(); 98 102 99 - // Remove @ prefix if present 100 - if (handle.startsWith("@")) { 101 - handle = handle.slice(1); 102 - } 103 + // Remove @ prefix if present 104 + if (handle.startsWith("@")) { 105 + handle = handle.slice(1); 106 + } 103 107 104 - try { 105 - const client = await getOAuthClient(); 106 - const url = await client.authorize(handle, { 107 - scope: "atproto transition:generic", 108 - }); 108 + try { 109 + const client = await getOAuthClient(); 110 + const url = await client.authorize(handle, { 111 + scope: "atproto transition:generic", 112 + }); 109 113 110 - return c.redirect(url.toString()); 111 - } catch (error) { 112 - console.error("Login error:", error); 113 - return c.redirect("/auth/login?error=authorization_failed"); 114 - } 114 + return c.redirect(url.toString()); 115 + } catch (error) { 116 + console.error("Login error:", error); 117 + return c.redirect("/auth/login?error=authorization_failed"); 118 + } 115 119 }); 116 120 117 121 // OAuth callback 118 122 authRoutes.get("/callback", async (c) => { 119 - const url = new URL(c.req.url); 120 - const params = url.searchParams; 123 + const url = new URL(c.req.url); 124 + const params = url.searchParams; 121 125 122 - // Check for error from authorization server 123 - const error = params.get("error"); 124 - if (error) { 125 - console.error("OAuth error:", error, params.get("error_description")); 126 - return c.redirect("/auth/login?error=callback_failed"); 127 - } 126 + // Check for error from authorization server 127 + const error = params.get("error"); 128 + if (error) { 129 + console.error("OAuth error:", error, params.get("error_description")); 130 + return c.redirect("/auth/login?error=callback_failed"); 131 + } 128 132 129 - try { 130 - const client = await getOAuthClient(); 131 - const { session } = await client.callback(params); 133 + try { 134 + const client = await getOAuthClient(); 135 + const { session } = await client.callback(params); 132 136 133 - // Store the DID in a cookie for session management 134 - // The actual OAuth session is stored in the database by the OAuth client 135 - setCookie(c, "session", session.did, { 136 - httpOnly: true, 137 - secure: 138 - process.env.NODE_ENV === "production" || 139 - process.env.PUBLIC_URL?.startsWith("https"), 140 - sameSite: "Lax", 141 - maxAge: 60 * 60 * 24 * 7, // 7 days 142 - path: "/", 143 - }); 137 + // Store the DID in a cookie for session management 138 + // The actual OAuth session is stored in the database by the OAuth client 139 + setCookie(c, "session", session.did, { 140 + httpOnly: true, 141 + secure: 142 + process.env.NODE_ENV === "production" || 143 + process.env.PUBLIC_URL?.startsWith("https"), 144 + sameSite: "Lax", 145 + maxAge: 60 * 60 * 24 * 7, // 7 days 146 + path: "/", 147 + }); 144 148 145 - return c.redirect("/"); 146 - } catch (error) { 147 - console.error("Callback error:", error); 148 - return c.redirect("/auth/login?error=callback_failed"); 149 - } 149 + return c.redirect("/"); 150 + } catch (error) { 151 + console.error("Callback error:", error); 152 + return c.redirect("/auth/login?error=callback_failed"); 153 + } 150 154 }); 151 155 152 156 // Logout 153 157 authRoutes.get("/logout", async (c) => { 154 - const did = getCookie(c, "session"); 158 + const did = getCookie(c, "session"); 155 159 156 - if (did) { 157 - try { 158 - // Delete the OAuth session from the database 159 - await deleteSession(did); 160 - } catch (error) { 161 - console.error("Error deleting session:", error); 162 - } 163 - } 160 + if (did) { 161 + try { 162 + // Delete the OAuth session from the database 163 + await deleteSession(did); 164 + } catch (error) { 165 + console.error("Error deleting session:", error); 166 + } 167 + } 164 168 165 - deleteCookie(c, "session", { path: "/" }); 166 - return c.redirect("/"); 169 + deleteCookie(c, "session", { path: "/" }); 170 + return c.redirect("/"); 167 171 });
+401 -387
packages/web/src/routes/documents.ts
··· 5 5 import { csrfField } from "../lib/csrf"; 6 6 import { isValidTID } from "../lib/validation"; 7 7 import { 8 - createMarkdownContent, 9 - getDocumentContentText, 8 + createMarkdownContent, 9 + getDocumentContentText, 10 10 } from "../lib/content-types"; 11 11 import { marked } from "marked"; 12 12 import type { AppVariables } from "../types"; ··· 18 18 19 19 // List all documents 20 20 documentRoutes.get("/", async (c) => { 21 - let session: Session; 22 - try { 23 - session = requireAuth(c); 24 - } catch { 25 - return c.redirect("/auth/login"); 26 - } 21 + let session: Session; 22 + try { 23 + session = requireAuth(c); 24 + } catch { 25 + return c.redirect("/auth/login"); 26 + } 27 27 28 - const filter = c.req.query("filter") || "all"; 28 + const filter = c.req.query("filter") || "all"; 29 29 30 - try { 31 - const response = await session.agent!.com.atproto.repo.listRecords({ 32 - repo: session.did!, 33 - collection: DOCUMENT_COLLECTION, 34 - limit: 100, 35 - }); 30 + try { 31 + const response = await session.agent!.com.atproto.repo.listRecords({ 32 + repo: session.did!, 33 + collection: DOCUMENT_COLLECTION, 34 + limit: 100, 35 + }); 36 36 37 - let documents = response.data.records; 37 + let documents = response.data.records; 38 38 39 - // Filter by draft/published status 40 - if (filter === "drafts") { 41 - documents = documents.filter((doc: any) => { 42 - const tags = doc.value.tags || []; 43 - return tags.includes("draft"); 44 - }); 45 - } else if (filter === "published") { 46 - documents = documents.filter((doc: any) => { 47 - const tags = doc.value.tags || []; 48 - return !tags.includes("draft"); 49 - }); 50 - } 39 + // Filter by draft/published status 40 + if (filter === "drafts") { 41 + documents = documents.filter((doc: any) => { 42 + const tags = doc.value.tags || []; 43 + return tags.includes("draft"); 44 + }); 45 + } else if (filter === "published") { 46 + documents = documents.filter((doc: any) => { 47 + const tags = doc.value.tags || []; 48 + return !tags.includes("draft"); 49 + }); 50 + } 51 51 52 - // Sort by publishedAt or updatedAt 53 - documents.sort((a: any, b: any) => { 54 - const dateA = new Date( 55 - a.value.updatedAt || a.value.publishedAt, 56 - ).getTime(); 57 - const dateB = new Date( 58 - b.value.updatedAt || b.value.publishedAt, 59 - ).getTime(); 60 - return dateB - dateA; 61 - }); 52 + // Sort by publishedAt or updatedAt 53 + documents.sort((a: any, b: any) => { 54 + const dateA = new Date( 55 + a.value.updatedAt || a.value.publishedAt, 56 + ).getTime(); 57 + const dateB = new Date( 58 + b.value.updatedAt || b.value.publishedAt, 59 + ).getTime(); 60 + return dateB - dateA; 61 + }); 62 62 63 - const content = html` 63 + const content = html` 64 64 <div class="documents"> 65 65 <div class="documents-header"> 66 66 <h1>Documents</h1> ··· 85 85 > 86 86 </div> 87 87 88 - ${documents.length === 0 89 - ? html` 88 + ${ 89 + documents.length === 0 90 + ? html` 90 91 <p class="empty"> 91 92 No documents yet. 92 93 <a href="/documents/new">Create your first document</a>. 93 94 </p> 94 95 ` 95 - : html` 96 + : html` 96 97 <ul class="document-list"> 97 98 ${documents.map((doc: any) => { 98 - const rkey = doc.uri.split("/").pop(); 99 - const value = doc.value; 100 - const isDraft = (value.tags || []).includes("draft"); 101 - const date = value.publishedAt 102 - ? new Date(value.publishedAt).toLocaleDateString() 103 - : ""; 99 + const rkey = doc.uri.split("/").pop(); 100 + const value = doc.value; 101 + const isDraft = (value.tags || []).includes("draft"); 102 + const date = value.publishedAt 103 + ? new Date(value.publishedAt).toLocaleDateString() 104 + : ""; 104 105 105 - return html` 106 + return html` 106 107 <li 107 108 class="document-item ${isDraft ? "draft" : "published"}" 108 109 > 109 110 <a href="/documents/${rkey}"> 110 111 <span class="title">${value.title}</span> 111 112 <span class="meta"> 112 - ${isDraft 113 - ? html`<span class="badge badge-draft">Draft</span>` 114 - : ""} 113 + ${ 114 + isDraft 115 + ? html`<span class="badge badge-draft">Draft</span>` 116 + : "" 117 + } 115 118 <span class="date">${date}</span> 116 119 </span> 117 120 </a> 118 121 </li> 119 122 `; 120 - })} 123 + })} 121 124 </ul> 122 - `} 125 + ` 126 + } 123 127 </div> 124 128 `; 125 129 126 - return c.html(layout(content, { title: "Documents - sitebase", session })); 127 - } catch (error) { 128 - console.error("Error fetching documents:", error); 129 - const content = html`<p class="error"> 130 + return c.html(layout(content, { title: "Documents - sitebase", session })); 131 + } catch (error) { 132 + console.error("Error fetching documents:", error); 133 + const content = html`<p class="error"> 130 134 Error loading documents. Please try again. 131 135 </p>`; 132 - return c.html(layout(content, { title: "Documents - sitebase", session })); 133 - } 136 + return c.html(layout(content, { title: "Documents - sitebase", session })); 137 + } 134 138 }); 135 139 136 140 // New document form 137 141 documentRoutes.get("/new", async (c) => { 138 - let session: Session; 139 - try { 140 - session = requireAuth(c); 141 - } catch { 142 - return c.redirect("/auth/login"); 143 - } 142 + let session: Session; 143 + try { 144 + session = requireAuth(c); 145 + } catch { 146 + return c.redirect("/auth/login"); 147 + } 144 148 145 - // Get publication to use as site reference 146 - let publicationUri = ""; 147 - try { 148 - const response = await session.agent!.com.atproto.repo.listRecords({ 149 - repo: session.did!, 150 - collection: PUBLICATION_COLLECTION, 151 - limit: 1, 152 - }); 153 - if (response.data.records[0]) { 154 - publicationUri = response.data.records[0].uri; 155 - } 156 - } catch (e) { 157 - // No publication yet, will need URL 158 - } 149 + // Get publication to use as site reference 150 + let publicationUri = ""; 151 + try { 152 + const response = await session.agent!.com.atproto.repo.listRecords({ 153 + repo: session.did!, 154 + collection: PUBLICATION_COLLECTION, 155 + limit: 1, 156 + }); 157 + if (response.data.records[0]) { 158 + publicationUri = response.data.records[0].uri; 159 + } 160 + } catch (e) { 161 + // No publication yet, will need URL 162 + } 159 163 160 - const csrfToken = c.get("csrfToken") as string; 164 + const csrfToken = c.get("csrfToken") as string; 161 165 162 - const content = html` 166 + const content = html` 163 167 <div class="form-page"> 164 168 <h1>New Document</h1> 165 169 ··· 277 281 </script> 278 282 `; 279 283 280 - return c.html(layout(content, { title: "New Document - sitebase", session })); 284 + return c.html(layout(content, { title: "New Document - sitebase", session })); 281 285 }); 282 286 283 287 // Handle document creation 284 288 documentRoutes.post("/new", async (c) => { 285 - let session: Session; 286 - try { 287 - session = requireAuth(c); 288 - } catch { 289 - return c.redirect("/auth/login"); 290 - } 289 + let session: Session; 290 + try { 291 + session = requireAuth(c); 292 + } catch { 293 + return c.redirect("/auth/login"); 294 + } 291 295 292 - const body = await c.req.parseBody(); 293 - const title = body.title as string; 294 - const path = (body.path as string) || undefined; 295 - const description = (body.description as string) || undefined; 296 - const content = (body.content as string) || undefined; 297 - const tagsStr = (body.tags as string) || ""; 298 - const action = body.action as string; 299 - const publicationUri = body.publicationUri as string; 300 - const publishDateStr = body.publishDate as string; 296 + const body = await c.req.parseBody(); 297 + const title = body.title as string; 298 + const path = (body.path as string) || undefined; 299 + const description = (body.description as string) || undefined; 300 + const content = (body.content as string) || undefined; 301 + const tagsStr = (body.tags as string) || ""; 302 + const action = body.action as string; 303 + const publicationUri = body.publicationUri as string; 304 + const publishDateStr = body.publishDate as string; 301 305 302 - // Parse tags 303 - let tags = tagsStr 304 - .split(",") 305 - .map((t) => t.trim()) 306 - .filter((t) => t); 306 + // Parse tags 307 + let tags = tagsStr 308 + .split(",") 309 + .map((t) => t.trim()) 310 + .filter((t) => t); 307 311 308 - // If publishing, remove draft tag 309 - if (action === "publish") { 310 - tags = tags.filter((t) => t !== "draft"); 311 - } else if (!tags.includes("draft")) { 312 - tags.push("draft"); 313 - } 312 + // If publishing, remove draft tag 313 + if (action === "publish") { 314 + tags = tags.filter((t) => t !== "draft"); 315 + } else if (!tags.includes("draft")) { 316 + tags.push("draft"); 317 + } 314 318 315 - const now = new Date().toISOString(); 319 + const now = new Date().toISOString(); 316 320 317 - // Determine publish date 318 - let publishedAt: string | undefined; 319 - if (action === "publish") { 320 - if (publishDateStr) { 321 - const parsedDate = new Date(publishDateStr); 322 - if (!isNaN(parsedDate.getTime())) { 323 - publishedAt = parsedDate.toISOString(); 324 - } 325 - } 326 - if (!publishedAt) { 327 - publishedAt = now; 328 - } 329 - } 321 + // Determine publish date 322 + let publishedAt: string | undefined; 323 + if (action === "publish") { 324 + if (publishDateStr) { 325 + const parsedDate = new Date(publishDateStr); 326 + if (!isNaN(parsedDate.getTime())) { 327 + publishedAt = parsedDate.toISOString(); 328 + } 329 + } 330 + if (!publishedAt) { 331 + publishedAt = now; 332 + } 333 + } 330 334 331 - try { 332 - const rkey = generateTID(); 335 + try { 336 + const rkey = generateTID(); 333 337 334 - // Determine site reference 335 - let site = publicationUri; 336 - if (!site) { 337 - // Fall back to a URL if no publication 338 - site = `https://${session.handle}.bsky.social`; 339 - } 338 + // Determine site reference 339 + let site = publicationUri; 340 + if (!site) { 341 + // Fall back to a URL if no publication 342 + site = `https://${session.handle}.bsky.social`; 343 + } 340 344 341 - const record: Record<string, any> = { 342 - $type: DOCUMENT_COLLECTION, 343 - title, 344 - site, 345 - publishedAt, 346 - updatedAt: now, 347 - }; 345 + const record: Record<string, any> = { 346 + $type: DOCUMENT_COLLECTION, 347 + title, 348 + site, 349 + publishedAt, 350 + updatedAt: now, 351 + }; 348 352 349 - if (path) record.path = path.startsWith("/") ? path : `/${path}`; 350 - if (description) record.description = description; 351 - if (content) { 352 - record.content = createMarkdownContent(content); 353 - record.textContent = content; 354 - } 355 - if (tags.length > 0) record.tags = tags; 353 + if (path) record.path = path.startsWith("/") ? path : `/${path}`; 354 + if (description) record.description = description; 355 + if (content) { 356 + record.content = createMarkdownContent(content); 357 + record.textContent = content; 358 + } 359 + if (tags.length > 0) record.tags = tags; 356 360 357 - await session.agent!.com.atproto.repo.createRecord({ 358 - repo: session.did!, 359 - collection: DOCUMENT_COLLECTION, 360 - rkey, 361 - record, 362 - }); 361 + await session.agent!.com.atproto.repo.createRecord({ 362 + repo: session.did!, 363 + collection: DOCUMENT_COLLECTION, 364 + rkey, 365 + record, 366 + }); 363 367 364 - return c.redirect(`/documents/${rkey}`); 365 - } catch (error) { 366 - console.error("Error creating document:", error); 367 - return c.redirect("/documents/new?error=create_failed"); 368 - } 368 + return c.redirect(`/documents/${rkey}`); 369 + } catch (error) { 370 + console.error("Error creating document:", error); 371 + return c.redirect("/documents/new?error=create_failed"); 372 + } 369 373 }); 370 374 371 375 // View single document 372 376 documentRoutes.get("/:rkey", async (c) => { 373 - let session: Session; 374 - try { 375 - session = requireAuth(c); 376 - } catch { 377 - return c.redirect("/auth/login"); 378 - } 377 + let session: Session; 378 + try { 379 + session = requireAuth(c); 380 + } catch { 381 + return c.redirect("/auth/login"); 382 + } 379 383 380 - const rkey = c.req.param("rkey"); 384 + const rkey = c.req.param("rkey"); 381 385 382 - // Validate rkey format 383 - if (!isValidTID(rkey)) { 384 - return c.redirect("/documents"); 385 - } 386 + // Validate rkey format 387 + if (!isValidTID(rkey)) { 388 + return c.redirect("/documents"); 389 + } 386 390 387 - try { 388 - const response = await session.agent!.com.atproto.repo.getRecord({ 389 - repo: session.did!, 390 - collection: DOCUMENT_COLLECTION, 391 - rkey, 392 - }); 391 + try { 392 + const response = await session.agent!.com.atproto.repo.getRecord({ 393 + repo: session.did!, 394 + collection: DOCUMENT_COLLECTION, 395 + rkey, 396 + }); 393 397 394 - const doc = response.data.value as any; 395 - const isDraft = (doc.tags || []).includes("draft"); 396 - const csrfToken = c.get("csrfToken") as string; 398 + const doc = response.data.value as any; 399 + const isDraft = (doc.tags || []).includes("draft"); 400 + const csrfToken = c.get("csrfToken") as string; 397 401 398 - const content = html` 402 + const content = html` 399 403 <div class="document-view"> 400 404 <div class="document-header"> 401 405 <h1>${doc.title}</h1> 402 406 <div class="document-meta"> 403 - ${isDraft 404 - ? html`<span class="badge badge-draft">Draft</span>` 405 - : html`<span class="badge badge-published">Published</span>`} 406 - ${doc.publishedAt 407 - ? html`<span class="date" 407 + ${ 408 + isDraft 409 + ? html`<span class="badge badge-draft">Draft</span>` 410 + : html`<span class="badge badge-published">Published</span>` 411 + } 412 + ${ 413 + doc.publishedAt 414 + ? html`<span class="date" 408 415 >Published: 409 416 ${new Date(doc.publishedAt).toLocaleDateString()}</span 410 417 >` 411 - : ""} 418 + : "" 419 + } 412 420 ${doc.path ? html`<span class="path">Path: ${doc.path}</span>` : ""} 413 421 </div> 414 422 </div> 415 423 416 - ${doc.description 417 - ? html`<p class="description">${doc.description}</p>` 418 - : ""} 424 + ${ 425 + doc.description 426 + ? html`<p class="description">${doc.description}</p>` 427 + : "" 428 + } 419 429 420 430 <div class="document-content"> 421 431 ${(() => { 422 - const text = getDocumentContentText(doc); 423 - if (!text) return html`<p class="empty">(No content)</p>`; 424 - const htmlContent = marked.parse(text) as string; 425 - return html`<div class="markdown-body">${raw(htmlContent)}</div>`; 426 - })()} 432 + const text = getDocumentContentText(doc); 433 + if (!text) return html`<p class="empty">(No content)</p>`; 434 + const htmlContent = marked.parse(text) as string; 435 + return html`<div class="markdown-body">${raw(htmlContent)}</div>`; 436 + })()} 427 437 </div> 428 438 429 439 <div class="actions"> 430 440 <a href="/documents/${rkey}/edit" class="btn btn-primary">Edit</a> 431 - ${isDraft 432 - ? html` 441 + ${ 442 + isDraft 443 + ? html` 433 444 <form 434 445 action="/documents/${rkey}/publish" 435 446 method="POST" ··· 439 450 <button type="submit" class="btn btn-success">Publish</button> 440 451 </form> 441 452 ` 442 - : html` 453 + : html` 443 454 <form 444 455 action="/documents/${rkey}/unpublish" 445 456 method="POST" ··· 450 461 Unpublish 451 462 </button> 452 463 </form> 453 - `} 464 + ` 465 + } 454 466 <form 455 467 action="/documents/${rkey}/delete" 456 468 method="POST" ··· 465 477 </div> 466 478 `; 467 479 468 - return c.html( 469 - layout(content, { title: `${doc.title} - sitebase`, session }), 470 - ); 471 - } catch (error) { 472 - console.error("Error fetching document:", error); 473 - return c.redirect("/documents"); 474 - } 480 + return c.html( 481 + layout(content, { title: `${doc.title} - sitebase`, session }), 482 + ); 483 + } catch (error) { 484 + console.error("Error fetching document:", error); 485 + return c.redirect("/documents"); 486 + } 475 487 }); 476 488 477 489 // Edit document form 478 490 documentRoutes.get("/:rkey/edit", async (c) => { 479 - let session: Session; 480 - try { 481 - session = requireAuth(c); 482 - } catch { 483 - return c.redirect("/auth/login"); 484 - } 491 + let session: Session; 492 + try { 493 + session = requireAuth(c); 494 + } catch { 495 + return c.redirect("/auth/login"); 496 + } 485 497 486 - const rkey = c.req.param("rkey"); 498 + const rkey = c.req.param("rkey"); 487 499 488 - if (!isValidTID(rkey)) { 489 - return c.redirect("/documents"); 490 - } 500 + if (!isValidTID(rkey)) { 501 + return c.redirect("/documents"); 502 + } 491 503 492 - try { 493 - const response = await session.agent!.com.atproto.repo.getRecord({ 494 - repo: session.did!, 495 - collection: DOCUMENT_COLLECTION, 496 - rkey, 497 - }); 504 + try { 505 + const response = await session.agent!.com.atproto.repo.getRecord({ 506 + repo: session.did!, 507 + collection: DOCUMENT_COLLECTION, 508 + rkey, 509 + }); 498 510 499 - const doc = response.data.value as any; 500 - const csrfToken = c.get("csrfToken") as string; 511 + const doc = response.data.value as any; 512 + const csrfToken = c.get("csrfToken") as string; 501 513 502 - const content = html` 514 + const content = html` 503 515 <div class="form-page"> 504 516 <h1>Edit Document</h1> 505 517 ··· 571 583 type="datetime-local" 572 584 id="publishDate" 573 585 name="publishDate" 574 - value="${doc.publishedAt 575 - ? new Date(doc.publishedAt).toISOString().slice(0, 16) 576 - : ""}" 586 + value="${ 587 + doc.publishedAt 588 + ? new Date(doc.publishedAt).toISOString().slice(0, 16) 589 + : "" 590 + }" 577 591 /> 578 592 <small 579 593 >Only past dates allowed. Set to change the published date.</small ··· 606 620 </script> 607 621 `; 608 622 609 - return c.html( 610 - layout(content, { title: `Edit: ${doc.title} - sitebase`, session }), 611 - ); 612 - } catch (error) { 613 - console.error("Error fetching document:", error); 614 - return c.redirect("/documents"); 615 - } 623 + return c.html( 624 + layout(content, { title: `Edit: ${doc.title} - sitebase`, session }), 625 + ); 626 + } catch (error) { 627 + console.error("Error fetching document:", error); 628 + return c.redirect("/documents"); 629 + } 616 630 }); 617 631 618 632 // Handle document update 619 633 documentRoutes.post("/:rkey/edit", async (c) => { 620 - let session: Session; 621 - try { 622 - session = requireAuth(c); 623 - } catch { 624 - return c.redirect("/auth/login"); 625 - } 634 + let session: Session; 635 + try { 636 + session = requireAuth(c); 637 + } catch { 638 + return c.redirect("/auth/login"); 639 + } 626 640 627 - const rkey = c.req.param("rkey"); 641 + const rkey = c.req.param("rkey"); 628 642 629 - if (!isValidTID(rkey)) { 630 - return c.redirect("/documents"); 631 - } 643 + if (!isValidTID(rkey)) { 644 + return c.redirect("/documents"); 645 + } 632 646 633 - const body = await c.req.parseBody(); 647 + const body = await c.req.parseBody(); 634 648 635 - try { 636 - // Get existing record 637 - const existing = await session.agent!.com.atproto.repo.getRecord({ 638 - repo: session.did!, 639 - collection: DOCUMENT_COLLECTION, 640 - rkey, 641 - }); 649 + try { 650 + // Get existing record 651 + const existing = await session.agent!.com.atproto.repo.getRecord({ 652 + repo: session.did!, 653 + collection: DOCUMENT_COLLECTION, 654 + rkey, 655 + }); 642 656 643 - const oldDoc = existing.data.value as any; 657 + const oldDoc = existing.data.value as any; 644 658 645 - const title = body.title as string; 646 - const path = (body.path as string) || undefined; 647 - const description = (body.description as string) || undefined; 648 - const content = (body.content as string) || undefined; 649 - const tagsStr = (body.tags as string) || ""; 650 - const publishDateStr = body.publishDate as string; 651 - const tags = tagsStr 652 - .split(",") 653 - .map((t) => t.trim()) 654 - .filter((t) => t); 659 + const title = body.title as string; 660 + const path = (body.path as string) || undefined; 661 + const description = (body.description as string) || undefined; 662 + const content = (body.content as string) || undefined; 663 + const tagsStr = (body.tags as string) || ""; 664 + const publishDateStr = body.publishDate as string; 665 + const tags = tagsStr 666 + .split(",") 667 + .map((t) => t.trim()) 668 + .filter((t) => t); 655 669 656 - // Determine publishedAt 657 - let publishedAt = oldDoc.publishedAt; 658 - if (publishDateStr) { 659 - const parsedDate = new Date(publishDateStr); 660 - if (!isNaN(parsedDate.getTime())) { 661 - publishedAt = parsedDate.toISOString(); 662 - } 663 - } 670 + // Determine publishedAt 671 + let publishedAt = oldDoc.publishedAt; 672 + if (publishDateStr) { 673 + const parsedDate = new Date(publishDateStr); 674 + if (!isNaN(parsedDate.getTime())) { 675 + publishedAt = parsedDate.toISOString(); 676 + } 677 + } 664 678 665 - const record: Record<string, any> = { 666 - $type: DOCUMENT_COLLECTION, 667 - title, 668 - site: oldDoc.site, 669 - publishedAt, 670 - updatedAt: new Date().toISOString(), 671 - }; 679 + const record: Record<string, any> = { 680 + $type: DOCUMENT_COLLECTION, 681 + title, 682 + site: oldDoc.site, 683 + publishedAt, 684 + updatedAt: new Date().toISOString(), 685 + }; 672 686 673 - if (path) record.path = path.startsWith("/") ? path : `/${path}`; 674 - if (description) record.description = description; 675 - if (content) { 676 - record.content = createMarkdownContent(content); 677 - record.textContent = content; 678 - } 679 - if (tags.length > 0) record.tags = tags; 687 + if (path) record.path = path.startsWith("/") ? path : `/${path}`; 688 + if (description) record.description = description; 689 + if (content) { 690 + record.content = createMarkdownContent(content); 691 + record.textContent = content; 692 + } 693 + if (tags.length > 0) record.tags = tags; 680 694 681 - await session.agent!.com.atproto.repo.putRecord({ 682 - repo: session.did!, 683 - collection: DOCUMENT_COLLECTION, 684 - rkey, 685 - record, 686 - }); 695 + await session.agent!.com.atproto.repo.putRecord({ 696 + repo: session.did!, 697 + collection: DOCUMENT_COLLECTION, 698 + rkey, 699 + record, 700 + }); 687 701 688 - return c.redirect(`/documents/${rkey}`); 689 - } catch (error) { 690 - console.error("Error updating document:", error); 691 - return c.redirect(`/documents/${rkey}/edit?error=update_failed`); 692 - } 702 + return c.redirect(`/documents/${rkey}`); 703 + } catch (error) { 704 + console.error("Error updating document:", error); 705 + return c.redirect(`/documents/${rkey}/edit?error=update_failed`); 706 + } 693 707 }); 694 708 695 709 // Publish document 696 710 documentRoutes.post("/:rkey/publish", async (c) => { 697 - let session: Session; 698 - try { 699 - session = requireAuth(c); 700 - } catch { 701 - return c.redirect("/auth/login"); 702 - } 711 + let session: Session; 712 + try { 713 + session = requireAuth(c); 714 + } catch { 715 + return c.redirect("/auth/login"); 716 + } 703 717 704 - const rkey = c.req.param("rkey"); 718 + const rkey = c.req.param("rkey"); 705 719 706 - if (!isValidTID(rkey)) { 707 - return c.redirect("/documents"); 708 - } 720 + if (!isValidTID(rkey)) { 721 + return c.redirect("/documents"); 722 + } 709 723 710 - try { 711 - const existing = await session.agent!.com.atproto.repo.getRecord({ 712 - repo: session.did!, 713 - collection: DOCUMENT_COLLECTION, 714 - rkey, 715 - }); 724 + try { 725 + const existing = await session.agent!.com.atproto.repo.getRecord({ 726 + repo: session.did!, 727 + collection: DOCUMENT_COLLECTION, 728 + rkey, 729 + }); 716 730 717 - const doc = existing.data.value as any; 718 - const tags = (doc.tags || []).filter((t: string) => t !== "draft"); 731 + const doc = existing.data.value as any; 732 + const tags = (doc.tags || []).filter((t: string) => t !== "draft"); 719 733 720 - const record = { 721 - ...doc, 722 - tags: tags.length > 0 ? tags : undefined, 723 - publishedAt: doc.publishedAt || new Date().toISOString(), 724 - updatedAt: new Date().toISOString(), 725 - }; 734 + const record = { 735 + ...doc, 736 + tags: tags.length > 0 ? tags : undefined, 737 + publishedAt: doc.publishedAt || new Date().toISOString(), 738 + updatedAt: new Date().toISOString(), 739 + }; 726 740 727 - await session.agent!.com.atproto.repo.putRecord({ 728 - repo: session.did!, 729 - collection: DOCUMENT_COLLECTION, 730 - rkey, 731 - record, 732 - }); 741 + await session.agent!.com.atproto.repo.putRecord({ 742 + repo: session.did!, 743 + collection: DOCUMENT_COLLECTION, 744 + rkey, 745 + record, 746 + }); 733 747 734 - return c.redirect(`/documents/${rkey}`); 735 - } catch (error) { 736 - console.error("Error publishing document:", error); 737 - return c.redirect(`/documents/${rkey}?error=publish_failed`); 738 - } 748 + return c.redirect(`/documents/${rkey}`); 749 + } catch (error) { 750 + console.error("Error publishing document:", error); 751 + return c.redirect(`/documents/${rkey}?error=publish_failed`); 752 + } 739 753 }); 740 754 741 755 // Unpublish document (add draft tag) 742 756 documentRoutes.post("/:rkey/unpublish", async (c) => { 743 - let session: Session; 744 - try { 745 - session = requireAuth(c); 746 - } catch { 747 - return c.redirect("/auth/login"); 748 - } 757 + let session: Session; 758 + try { 759 + session = requireAuth(c); 760 + } catch { 761 + return c.redirect("/auth/login"); 762 + } 749 763 750 - const rkey = c.req.param("rkey"); 764 + const rkey = c.req.param("rkey"); 751 765 752 - if (!isValidTID(rkey)) { 753 - return c.redirect("/documents"); 754 - } 766 + if (!isValidTID(rkey)) { 767 + return c.redirect("/documents"); 768 + } 755 769 756 - try { 757 - const existing = await session.agent!.com.atproto.repo.getRecord({ 758 - repo: session.did!, 759 - collection: DOCUMENT_COLLECTION, 760 - rkey, 761 - }); 770 + try { 771 + const existing = await session.agent!.com.atproto.repo.getRecord({ 772 + repo: session.did!, 773 + collection: DOCUMENT_COLLECTION, 774 + rkey, 775 + }); 762 776 763 - const doc = existing.data.value as any; 764 - const tags = [...(doc.tags || []), "draft"]; 777 + const doc = existing.data.value as any; 778 + const tags = [...(doc.tags || []), "draft"]; 765 779 766 - const record = { 767 - ...doc, 768 - tags, 769 - updatedAt: new Date().toISOString(), 770 - }; 780 + const record = { 781 + ...doc, 782 + tags, 783 + updatedAt: new Date().toISOString(), 784 + }; 771 785 772 - await session.agent!.com.atproto.repo.putRecord({ 773 - repo: session.did!, 774 - collection: DOCUMENT_COLLECTION, 775 - rkey, 776 - record, 777 - }); 786 + await session.agent!.com.atproto.repo.putRecord({ 787 + repo: session.did!, 788 + collection: DOCUMENT_COLLECTION, 789 + rkey, 790 + record, 791 + }); 778 792 779 - return c.redirect(`/documents/${rkey}`); 780 - } catch (error) { 781 - console.error("Error unpublishing document:", error); 782 - return c.redirect(`/documents/${rkey}?error=unpublish_failed`); 783 - } 793 + return c.redirect(`/documents/${rkey}`); 794 + } catch (error) { 795 + console.error("Error unpublishing document:", error); 796 + return c.redirect(`/documents/${rkey}?error=unpublish_failed`); 797 + } 784 798 }); 785 799 786 800 // Delete document 787 801 documentRoutes.post("/:rkey/delete", async (c) => { 788 - let session: Session; 789 - try { 790 - session = requireAuth(c); 791 - } catch { 792 - return c.redirect("/auth/login"); 793 - } 802 + let session: Session; 803 + try { 804 + session = requireAuth(c); 805 + } catch { 806 + return c.redirect("/auth/login"); 807 + } 794 808 795 - const rkey = c.req.param("rkey"); 809 + const rkey = c.req.param("rkey"); 796 810 797 - if (!isValidTID(rkey)) { 798 - return c.redirect("/documents"); 799 - } 811 + if (!isValidTID(rkey)) { 812 + return c.redirect("/documents"); 813 + } 800 814 801 - try { 802 - await session.agent!.com.atproto.repo.deleteRecord({ 803 - repo: session.did!, 804 - collection: DOCUMENT_COLLECTION, 805 - rkey, 806 - }); 815 + try { 816 + await session.agent!.com.atproto.repo.deleteRecord({ 817 + repo: session.did!, 818 + collection: DOCUMENT_COLLECTION, 819 + rkey, 820 + }); 807 821 808 - return c.redirect("/documents"); 809 - } catch (error) { 810 - console.error("Error deleting document:", error); 811 - return c.redirect(`/documents/${rkey}?error=delete_failed`); 812 - } 822 + return c.redirect("/documents"); 823 + } catch (error) { 824 + console.error("Error deleting document:", error); 825 + return c.redirect(`/documents/${rkey}?error=delete_failed`); 826 + } 813 827 }); 814 828 815 829 // Generate a TID (timestamp-based ID) 816 830 function generateTID(): string { 817 - const now = Date.now() * 1000; 818 - const clockId = Math.floor(Math.random() * 1024); 819 - const tid = (BigInt(now) << 10n) | BigInt(clockId); 820 - return tid.toString(36).padStart(13, "0"); 831 + const now = Date.now() * 1000; 832 + const clockId = Math.floor(Math.random() * 1024); 833 + const tid = (BigInt(now) << 10n) | BigInt(clockId); 834 + return tid.toString(36).padStart(13, "0"); 821 835 }
+131 -129
packages/web/src/routes/publication.ts
··· 11 11 12 12 // View/manage publication 13 13 publicationRoutes.get("/", async (c) => { 14 - let session: Session; 15 - try { 16 - session = requireAuth(c); 17 - } catch { 18 - return c.redirect("/auth/login"); 19 - } 14 + let session: Session; 15 + try { 16 + session = requireAuth(c); 17 + } catch { 18 + return c.redirect("/auth/login"); 19 + } 20 20 21 - try { 22 - // Fetch existing publication 23 - const response = await session.agent!.com.atproto.repo.listRecords({ 24 - repo: session.did!, 25 - collection: PUBLICATION_COLLECTION, 26 - limit: 1, 27 - }); 21 + try { 22 + // Fetch existing publication 23 + const response = await session.agent!.com.atproto.repo.listRecords({ 24 + repo: session.did!, 25 + collection: PUBLICATION_COLLECTION, 26 + limit: 1, 27 + }); 28 28 29 - const publication = response.data.records[0]; 29 + const publication = response.data.records[0]; 30 30 31 - if (publication) { 32 - const pub = publication.value as any; 33 - const content = html` 31 + if (publication) { 32 + const pub = publication.value as any; 33 + const content = html` 34 34 <div class="publication"> 35 35 <h1>Your Publication</h1> 36 36 ··· 39 39 <p class="url"> 40 40 <a href="${pub.url}" target="_blank">${pub.url}</a> 41 41 </p> 42 - ${pub.description 43 - ? html`<p class="description">${pub.description}</p>` 44 - : ""} 42 + ${ 43 + pub.description 44 + ? html`<p class="description">${pub.description}</p>` 45 + : "" 46 + } 45 47 </div> 46 48 47 49 <div class="actions"> ··· 51 53 </div> 52 54 </div> 53 55 `; 54 - return c.html( 55 - layout(content, { title: "Publication - sitebase", session }), 56 - ); 57 - } 56 + return c.html( 57 + layout(content, { title: "Publication - sitebase", session }), 58 + ); 59 + } 58 60 59 - // No publication exists, show create form 60 - return c.redirect("/publication/new"); 61 - } catch (error) { 62 - console.error("Error fetching publication:", error); 63 - return c.redirect("/publication/new"); 64 - } 61 + // No publication exists, show create form 62 + return c.redirect("/publication/new"); 63 + } catch (error) { 64 + console.error("Error fetching publication:", error); 65 + return c.redirect("/publication/new"); 66 + } 65 67 }); 66 68 67 69 // New publication form 68 70 publicationRoutes.get("/new", async (c) => { 69 - let session: Session; 70 - try { 71 - session = requireAuth(c); 72 - } catch { 73 - return c.redirect("/auth/login"); 74 - } 71 + let session: Session; 72 + try { 73 + session = requireAuth(c); 74 + } catch { 75 + return c.redirect("/auth/login"); 76 + } 75 77 76 - const csrfToken = c.get("csrfToken") as string; 78 + const csrfToken = c.get("csrfToken") as string; 77 79 78 - const content = html` 80 + const content = html` 79 81 <div class="form-page"> 80 82 <h1>Create Publication</h1> 81 83 ··· 117 119 </div> 118 120 `; 119 121 120 - return c.html( 121 - layout(content, { title: "New Publication - sitebase", session }), 122 - ); 122 + return c.html( 123 + layout(content, { title: "New Publication - sitebase", session }), 124 + ); 123 125 }); 124 126 125 127 // Handle publication creation 126 128 publicationRoutes.post("/new", async (c) => { 127 - let session: Session; 128 - try { 129 - session = requireAuth(c); 130 - } catch { 131 - return c.redirect("/auth/login"); 132 - } 129 + let session: Session; 130 + try { 131 + session = requireAuth(c); 132 + } catch { 133 + return c.redirect("/auth/login"); 134 + } 133 135 134 - const body = await c.req.parseBody(); 135 - const name = body.name as string; 136 - const url = (body.url as string).replace(/\/$/, ""); // Remove trailing slash 137 - const description = (body.description as string) || undefined; 136 + const body = await c.req.parseBody(); 137 + const name = body.name as string; 138 + const url = (body.url as string).replace(/\/$/, ""); // Remove trailing slash 139 + const description = (body.description as string) || undefined; 138 140 139 - try { 140 - // Generate a TID for the record key 141 - const rkey = generateTID(); 141 + try { 142 + // Generate a TID for the record key 143 + const rkey = generateTID(); 142 144 143 - await session.agent!.com.atproto.repo.createRecord({ 144 - repo: session.did!, 145 - collection: PUBLICATION_COLLECTION, 146 - rkey, 147 - record: { 148 - $type: PUBLICATION_COLLECTION, 149 - name, 150 - url, 151 - ...(description && { description }), 152 - }, 153 - }); 145 + await session.agent!.com.atproto.repo.createRecord({ 146 + repo: session.did!, 147 + collection: PUBLICATION_COLLECTION, 148 + rkey, 149 + record: { 150 + $type: PUBLICATION_COLLECTION, 151 + name, 152 + url, 153 + ...(description && { description }), 154 + }, 155 + }); 154 156 155 - return c.redirect("/publication"); 156 - } catch (error) { 157 - console.error("Error creating publication:", error); 158 - return c.redirect("/publication/new?error=create_failed"); 159 - } 157 + return c.redirect("/publication"); 158 + } catch (error) { 159 + console.error("Error creating publication:", error); 160 + return c.redirect("/publication/new?error=create_failed"); 161 + } 160 162 }); 161 163 162 164 // Edit publication form 163 165 publicationRoutes.get("/edit", async (c) => { 164 - let session: Session; 165 - try { 166 - session = requireAuth(c); 167 - } catch { 168 - return c.redirect("/auth/login"); 169 - } 166 + let session: Session; 167 + try { 168 + session = requireAuth(c); 169 + } catch { 170 + return c.redirect("/auth/login"); 171 + } 170 172 171 - try { 172 - const response = await session.agent!.com.atproto.repo.listRecords({ 173 - repo: session.did!, 174 - collection: PUBLICATION_COLLECTION, 175 - limit: 1, 176 - }); 173 + try { 174 + const response = await session.agent!.com.atproto.repo.listRecords({ 175 + repo: session.did!, 176 + collection: PUBLICATION_COLLECTION, 177 + limit: 1, 178 + }); 177 179 178 - const publication = response.data.records[0]; 179 - if (!publication) { 180 - return c.redirect("/publication/new"); 181 - } 180 + const publication = response.data.records[0]; 181 + if (!publication) { 182 + return c.redirect("/publication/new"); 183 + } 182 184 183 - const pub = publication.value as any; 184 - const rkey = publication.uri.split("/").pop(); 185 + const pub = publication.value as any; 186 + const rkey = publication.uri.split("/").pop(); 185 187 186 - const csrfToken = c.get("csrfToken") as string; 188 + const csrfToken = c.get("csrfToken") as string; 187 189 188 - const content = html` 190 + const content = html` 189 191 <div class="form-page"> 190 192 <h1>Edit Publication</h1> 191 193 ··· 228 230 </div> 229 231 `; 230 232 231 - return c.html( 232 - layout(content, { title: "Edit Publication - sitebase", session }), 233 - ); 234 - } catch (error) { 235 - console.error("Error fetching publication:", error); 236 - return c.redirect("/publication"); 237 - } 233 + return c.html( 234 + layout(content, { title: "Edit Publication - sitebase", session }), 235 + ); 236 + } catch (error) { 237 + console.error("Error fetching publication:", error); 238 + return c.redirect("/publication"); 239 + } 238 240 }); 239 241 240 242 // Handle publication update 241 243 publicationRoutes.post("/edit", async (c) => { 242 - let session: Session; 243 - try { 244 - session = requireAuth(c); 245 - } catch { 246 - return c.redirect("/auth/login"); 247 - } 244 + let session: Session; 245 + try { 246 + session = requireAuth(c); 247 + } catch { 248 + return c.redirect("/auth/login"); 249 + } 248 250 249 - const body = await c.req.parseBody(); 250 - const rkey = body.rkey as string; 251 - const name = body.name as string; 252 - const url = (body.url as string).replace(/\/$/, ""); 253 - const description = (body.description as string) || undefined; 251 + const body = await c.req.parseBody(); 252 + const rkey = body.rkey as string; 253 + const name = body.name as string; 254 + const url = (body.url as string).replace(/\/$/, ""); 255 + const description = (body.description as string) || undefined; 254 256 255 - try { 256 - await session.agent!.com.atproto.repo.putRecord({ 257 - repo: session.did!, 258 - collection: PUBLICATION_COLLECTION, 259 - rkey, 260 - record: { 261 - $type: PUBLICATION_COLLECTION, 262 - name, 263 - url, 264 - ...(description && { description }), 265 - }, 266 - }); 257 + try { 258 + await session.agent!.com.atproto.repo.putRecord({ 259 + repo: session.did!, 260 + collection: PUBLICATION_COLLECTION, 261 + rkey, 262 + record: { 263 + $type: PUBLICATION_COLLECTION, 264 + name, 265 + url, 266 + ...(description && { description }), 267 + }, 268 + }); 267 269 268 - return c.redirect("/publication"); 269 - } catch (error) { 270 - console.error("Error updating publication:", error); 271 - return c.redirect("/publication/edit?error=update_failed"); 272 - } 270 + return c.redirect("/publication"); 271 + } catch (error) { 272 + console.error("Error updating publication:", error); 273 + return c.redirect("/publication/edit?error=update_failed"); 274 + } 273 275 }); 274 276 275 277 // Generate a TID (timestamp-based ID) 276 278 function generateTID(): string { 277 - const now = Date.now() * 1000; // microseconds 278 - const clockId = Math.floor(Math.random() * 1024); 279 - const tid = (BigInt(now) << 10n) | BigInt(clockId); 280 - return tid.toString(36).padStart(13, "0"); 279 + const now = Date.now() * 1000; // microseconds 280 + const clockId = Math.floor(Math.random() * 1024); 281 + const tid = (BigInt(now) << 10n) | BigInt(clockId); 282 + return tid.toString(36).padStart(13, "0"); 281 283 }
+4 -4
packages/web/src/views/home.ts
··· 2 2 import type { Session } from "../lib/session"; 3 3 4 4 export function homePage(session: Session) { 5 - if (session.did) { 6 - return html` 5 + if (session.did) { 6 + return html` 7 7 <div class="dashboard"> 8 8 <h1>Welcome, @${session.handle}</h1> 9 9 <p>Manage your standard.site publication and documents.</p> ··· 15 15 </div> 16 16 </div> 17 17 `; 18 - } 18 + } 19 19 20 - return html` 20 + return html` 21 21 <div class="hero"> 22 22 <h1>sitebase</h1> 23 23 <p>
+10 -8
packages/web/src/views/layouts/main.ts
··· 3 3 import type { Session } from "../../lib/session"; 4 4 5 5 interface LayoutOptions { 6 - title?: string; 7 - session?: Session; 8 - csrfToken?: string; 6 + title?: string; 7 + session?: Session; 8 + csrfToken?: string; 9 9 } 10 10 11 11 type Content = string | HtmlEscapedString | Promise<HtmlEscapedString>; 12 12 13 13 export function layout(content: Content, options: LayoutOptions = {}) { 14 - const { title = "sitebase", session } = options; 14 + const { title = "sitebase", session } = options; 15 15 16 - return html` 16 + return html` 17 17 <!DOCTYPE html> 18 18 <html lang="en"> 19 19 <head> ··· 27 27 <nav class="nav"> 28 28 <a href="/" class="logo">sitebase</a> 29 29 <div class="nav-links"> 30 - ${session?.did 31 - ? html` 30 + ${ 31 + session?.did 32 + ? html` 32 33 <a href="/publication">Publication</a> 33 34 <a href="/documents">Documents</a> 34 35 <span class="handle">@${session.handle}</span> 35 36 <a href="/auth/logout">Logout</a> 36 37 ` 37 - : html` <a href="/auth/login">Login with Bluesky</a> `} 38 + : html` <a href="/auth/login">Login with Bluesky</a> ` 39 + } 38 40 </div> 39 41 </nav> 40 42 </header>
+3 -3
scripts/import-content.ts
··· 90 90 const kvMatch = trimmed.match(/^(\w+):\s*(.*)$/); 91 91 if (kvMatch) { 92 92 const [, key, rawValue] = kvMatch; 93 + if (!key) continue; 93 94 const value = rawValue?.trim(); 94 95 95 96 // Save previous author object ··· 165 166 } 166 167 // Remove date prefix (YYYY-MM-DD_) from filename 167 168 const parts = withoutExt.split("/"); 168 - const filename = parts[parts.length - 1]; 169 - const filenameWithoutDate = 170 - filename?.replace(/^\d{4}-\d{2}-\d{2}_/, "") || filename; 169 + const filename = parts[parts.length - 1] ?? ""; 170 + const filenameWithoutDate = filename.replace(/^\d{4}-\d{2}-\d{2}_/, ""); 171 171 parts[parts.length - 1] = filenameWithoutDate; 172 172 return `/${parts.join("/")}`; 173 173 }