this repo has no description

Formatting and linting

seth.computer c53aa39d aa8cc394

verified
+1268 -1154
+2
.gitignore
··· 32 32 33 33 # Finder (MacOS) folder config 34 34 .DS_Store 35 + 36 + packages/web/data
+34
biome.json
··· 1 + { 2 + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", 3 + "vcs": { 4 + "enabled": true, 5 + "clientKind": "git", 6 + "useIgnoreFile": true 7 + }, 8 + "files": { 9 + "ignoreUnknown": false 10 + }, 11 + "formatter": { 12 + "enabled": true, 13 + "indentStyle": "tab" 14 + }, 15 + "linter": { 16 + "enabled": true, 17 + "rules": { 18 + "recommended": true 19 + } 20 + }, 21 + "javascript": { 22 + "formatter": { 23 + "quoteStyle": "double" 24 + } 25 + }, 26 + "assist": { 27 + "enabled": true, 28 + "actions": { 29 + "source": { 30 + "organizeImports": "on" 31 + } 32 + } 33 + } 34 + }
+21
bun.lock
··· 4 4 "workspaces": { 5 5 "": { 6 6 "name": "stdpub", 7 + "devDependencies": { 8 + "@biomejs/biome": "^2.3.11", 9 + }, 7 10 }, 8 11 "packages/lib": { 9 12 "name": "@stdpub/lib", ··· 71 74 "@atproto/syntax": ["@atproto/syntax@0.4.2", "", {}, "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA=="], 72 75 73 76 "@atproto/xrpc": ["@atproto/xrpc@0.7.7", "", { "dependencies": { "@atproto/lexicon": "^0.6.0", "zod": "^3.23.8" } }, "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA=="], 77 + 78 + "@biomejs/biome": ["@biomejs/biome@2.3.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-x64": "2.3.11" }, "bin": { "biome": "bin/biome" } }, "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ=="], 79 + 80 + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA=="], 81 + 82 + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg=="], 83 + 84 + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g=="], 85 + 86 + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg=="], 87 + 88 + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg=="], 89 + 90 + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw=="], 91 + 92 + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw=="], 93 + 94 + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg=="], 74 95 75 96 "@stdpub/lib": ["@stdpub/lib@workspace:packages/lib"], 76 97
+10 -1
package.json
··· 3 3 "private": true, 4 4 "workspaces": [ 5 5 "packages/*" 6 - ] 6 + ], 7 + "devDependencies": { 8 + "@biomejs/biome": "^2.3.11" 9 + }, 10 + "scripts": { 11 + "format": "biome format packages/ --write", 12 + "format:check": "biome format packages/", 13 + "lint": "biome lint packages/ --write", 14 + "lint:check": "biome lint packages/" 15 + } 7 16 }
+9
packages/web/.dockerignore
··· 1 + node_modules 2 + .git 3 + .gitignore 4 + *.log 5 + .DS_Store 6 + .env 7 + .env.* 8 + data/private-key.json 9 + data/oauth.db
+21 -21
packages/web/package.json
··· 1 1 { 2 - "name": "@stdpub/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 - }, 10 - "devDependencies": { 11 - "@types/bun": "latest" 12 - }, 13 - "peerDependencies": { 14 - "typescript": "^5" 15 - }, 16 - "dependencies": { 17 - "@atproto/api": "^0.18.13", 18 - "@atproto/jwk-jose": "^0.1.11", 19 - "@atproto/oauth-client-node": "^0.3.15", 20 - "hono": "^4.11.3", 21 - "marked": "^15.0.0" 22 - } 2 + "name": "@stdpub/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 + }, 10 + "devDependencies": { 11 + "@types/bun": "latest" 12 + }, 13 + "peerDependencies": { 14 + "typescript": "^5" 15 + }, 16 + "dependencies": { 17 + "@atproto/api": "^0.18.13", 18 + "@atproto/jwk-jose": "^0.1.11", 19 + "@atproto/oauth-client-node": "^0.3.15", 20 + "hono": "^4.11.3", 21 + "marked": "^15.0.0" 22 + } 23 23 }
+228 -225
packages/web/public/styles.css
··· 1 1 :root { 2 - --bg: #fafafa; 3 - --bg-secondary: #f0f0f0; 4 - --text: #1a1a1a; 5 - --text-muted: #666; 6 - --border: #ddd; 7 - --primary: #0066cc; 8 - --primary-hover: #0052a3; 9 - --success: #22c55e; 10 - --danger: #ef4444; 11 - --draft: #f59e0b; 2 + --bg: #fafafa; 3 + --bg-secondary: #f0f0f0; 4 + --text: #1a1a1a; 5 + --text-muted: #666; 6 + --border: #ddd; 7 + --primary: #0066cc; 8 + --primary-hover: #0052a3; 9 + --success: #22c55e; 10 + --danger: #ef4444; 11 + --draft: #f59e0b; 12 12 } 13 13 14 14 @media (prefers-color-scheme: dark) { 15 - :root { 16 - --bg: #1a1a1a; 17 - --bg-secondary: #2a2a2a; 18 - --text: #f0f0f0; 19 - --text-muted: #999; 20 - --border: #333; 21 - --primary: #3b82f6; 22 - --primary-hover: #2563eb; 23 - } 15 + :root { 16 + --bg: #1a1a1a; 17 + --bg-secondary: #2a2a2a; 18 + --text: #f0f0f0; 19 + --text-muted: #999; 20 + --border: #333; 21 + --primary: #3b82f6; 22 + --primary-hover: #2563eb; 23 + } 24 24 } 25 25 26 26 * { 27 - box-sizing: border-box; 28 - margin: 0; 29 - padding: 0; 27 + box-sizing: border-box; 28 + margin: 0; 29 + padding: 0; 30 30 } 31 31 32 32 body { 33 - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 34 - background: var(--bg); 35 - color: var(--text); 36 - line-height: 1.6; 37 - min-height: 100vh; 38 - display: flex; 39 - flex-direction: column; 33 + font-family: 34 + system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 35 + background: var(--bg); 36 + color: var(--text); 37 + line-height: 1.6; 38 + min-height: 100vh; 39 + display: flex; 40 + flex-direction: column; 40 41 } 41 42 42 43 a { 43 - color: var(--primary); 44 - text-decoration: none; 44 + color: var(--primary); 45 + text-decoration: none; 45 46 } 46 47 47 48 a:hover { 48 - text-decoration: underline; 49 + text-decoration: underline; 49 50 } 50 51 51 52 /* Header */ 52 53 .header { 53 - background: var(--bg-secondary); 54 - border-bottom: 1px solid var(--border); 55 - padding: 1rem; 54 + background: var(--bg-secondary); 55 + border-bottom: 1px solid var(--border); 56 + padding: 1rem; 56 57 } 57 58 58 59 .nav { 59 - max-width: 1200px; 60 - margin: 0 auto; 61 - display: flex; 62 - justify-content: space-between; 63 - align-items: center; 60 + max-width: 1200px; 61 + margin: 0 auto; 62 + display: flex; 63 + justify-content: space-between; 64 + align-items: center; 64 65 } 65 66 66 67 .logo { 67 - font-size: 1.25rem; 68 - font-weight: 600; 69 - color: var(--text); 68 + font-size: 1.25rem; 69 + font-weight: 600; 70 + color: var(--text); 70 71 } 71 72 72 73 .logo:hover { 73 - text-decoration: none; 74 + text-decoration: none; 74 75 } 75 76 76 77 .nav-links { 77 - display: flex; 78 - gap: 1.5rem; 79 - align-items: center; 78 + display: flex; 79 + gap: 1.5rem; 80 + align-items: center; 80 81 } 81 82 82 83 .nav-links a { 83 - color: var(--text-muted); 84 + color: var(--text-muted); 84 85 } 85 86 86 87 .nav-links a:hover { 87 - color: var(--text); 88 + color: var(--text); 88 89 } 89 90 90 91 .handle { 91 - color: var(--text-muted); 92 - font-size: 0.9rem; 92 + color: var(--text-muted); 93 + font-size: 0.9rem; 93 94 } 94 95 95 96 /* Main content */ 96 97 .main { 97 - flex: 1; 98 - max-width: 1200px; 99 - margin: 0 auto; 100 - padding: 2rem 1rem; 101 - width: 100%; 98 + flex: 1; 99 + max-width: 1200px; 100 + margin: 0 auto; 101 + padding: 2rem 1rem; 102 + width: 100%; 102 103 } 103 104 104 105 /* Footer */ 105 106 .footer { 106 - background: var(--bg-secondary); 107 - border-top: 1px solid var(--border); 108 - padding: 1rem; 109 - text-align: center; 110 - color: var(--text-muted); 111 - font-size: 0.9rem; 107 + background: var(--bg-secondary); 108 + border-top: 1px solid var(--border); 109 + padding: 1rem; 110 + text-align: center; 111 + color: var(--text-muted); 112 + font-size: 0.9rem; 112 113 } 113 114 114 115 /* Buttons */ 115 116 .btn { 116 - display: inline-block; 117 - padding: 0.5rem 1rem; 118 - border: none; 119 - border-radius: 4px; 120 - font-size: 1rem; 121 - cursor: pointer; 122 - text-decoration: none; 117 + display: inline-block; 118 + padding: 0.5rem 1rem; 119 + border: none; 120 + border-radius: 4px; 121 + font-size: 1rem; 122 + cursor: pointer; 123 + text-decoration: none; 123 124 } 124 125 125 126 .btn:hover { 126 - text-decoration: none; 127 + text-decoration: none; 127 128 } 128 129 129 130 .btn-primary { 130 - background: var(--primary); 131 - color: white; 131 + background: var(--primary); 132 + color: white; 132 133 } 133 134 134 135 .btn-primary:hover { 135 - background: var(--primary-hover); 136 + background: var(--primary-hover); 136 137 } 137 138 138 139 .btn-secondary { 139 - background: var(--bg-secondary); 140 - color: var(--text); 141 - border: 1px solid var(--border); 140 + background: var(--bg-secondary); 141 + color: var(--text); 142 + border: 1px solid var(--border); 142 143 } 143 144 144 145 .btn-secondary:hover { 145 - background: var(--border); 146 + background: var(--border); 146 147 } 147 148 148 149 .btn-success { 149 - background: var(--success); 150 - color: white; 150 + background: var(--success); 151 + color: white; 151 152 } 152 153 153 154 .btn-danger { 154 - background: var(--danger); 155 - color: white; 155 + background: var(--danger); 156 + color: white; 156 157 } 157 158 158 159 .btn-large { 159 - padding: 0.75rem 2rem; 160 - font-size: 1.125rem; 160 + padding: 0.75rem 2rem; 161 + font-size: 1.125rem; 161 162 } 162 163 163 164 /* Forms */ 164 165 .form-group { 165 - margin-bottom: 1.5rem; 166 + margin-bottom: 1.5rem; 166 167 } 167 168 168 169 .form-group label { 169 - display: block; 170 - margin-bottom: 0.5rem; 171 - font-weight: 500; 170 + display: block; 171 + margin-bottom: 0.5rem; 172 + font-weight: 500; 172 173 } 173 174 174 175 .form-group input, 175 176 .form-group textarea { 176 - width: 100%; 177 - padding: 0.75rem; 178 - border: 1px solid var(--border); 179 - border-radius: 4px; 180 - font-size: 1rem; 181 - background: var(--bg); 182 - color: var(--text); 177 + width: 100%; 178 + padding: 0.75rem; 179 + border: 1px solid var(--border); 180 + border-radius: 4px; 181 + font-size: 1rem; 182 + background: var(--bg); 183 + color: var(--text); 183 184 } 184 185 185 186 .form-group input:focus, 186 187 .form-group textarea:focus { 187 - outline: none; 188 - border-color: var(--primary); 188 + outline: none; 189 + border-color: var(--primary); 189 190 } 190 191 191 192 .form-group small { 192 - display: block; 193 - margin-top: 0.25rem; 194 - color: var(--text-muted); 195 - font-size: 0.875rem; 193 + display: block; 194 + margin-top: 0.25rem; 195 + color: var(--text-muted); 196 + font-size: 0.875rem; 196 197 } 197 198 198 199 .form-actions { 199 - display: flex; 200 - gap: 1rem; 201 - margin-top: 2rem; 200 + display: flex; 201 + gap: 1rem; 202 + margin-top: 2rem; 202 203 } 203 204 204 205 .form-page { 205 - max-width: 800px; 206 + max-width: 800px; 206 207 } 207 208 208 209 .content-editor { 209 - font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, monospace; 210 - min-height: 400px; 211 - resize: vertical; 210 + font-family: 211 + ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Monaco, monospace; 212 + min-height: 400px; 213 + resize: vertical; 212 214 } 213 215 214 216 /* Auth form */ 215 217 .auth-form { 216 - max-width: 400px; 217 - margin: 2rem auto; 218 + max-width: 400px; 219 + margin: 2rem auto; 218 220 } 219 221 220 222 .auth-form h1 { 221 - margin-bottom: 1.5rem; 223 + margin-bottom: 1.5rem; 222 224 } 223 225 224 226 /* Home page */ 225 227 .hero { 226 - text-align: center; 227 - padding: 4rem 1rem; 228 + text-align: center; 229 + padding: 4rem 1rem; 228 230 } 229 231 230 232 .hero h1 { 231 - font-size: 3rem; 232 - margin-bottom: 1rem; 233 + font-size: 3rem; 234 + margin-bottom: 1rem; 233 235 } 234 236 235 237 .hero p { 236 - font-size: 1.25rem; 237 - color: var(--text-muted); 238 - max-width: 600px; 239 - margin: 0 auto 3rem; 238 + font-size: 1.25rem; 239 + color: var(--text-muted); 240 + max-width: 600px; 241 + margin: 0 auto 3rem; 240 242 } 241 243 242 244 .features { 243 - display: grid; 244 - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 245 - gap: 2rem; 246 - margin-bottom: 3rem; 247 - text-align: left; 245 + display: grid; 246 + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 247 + gap: 2rem; 248 + margin-bottom: 3rem; 249 + text-align: left; 248 250 } 249 251 250 252 .feature { 251 - background: var(--bg-secondary); 252 - padding: 1.5rem; 253 - border-radius: 8px; 254 - border: 1px solid var(--border); 253 + background: var(--bg-secondary); 254 + padding: 1.5rem; 255 + border-radius: 8px; 256 + border: 1px solid var(--border); 255 257 } 256 258 257 259 .feature h3 { 258 - margin-bottom: 0.5rem; 260 + margin-bottom: 0.5rem; 259 261 } 260 262 261 263 .feature p { 262 - font-size: 1rem; 263 - margin: 0; 264 + font-size: 1rem; 265 + margin: 0; 264 266 } 265 267 266 268 /* Dashboard */ 267 269 .dashboard { 268 - max-width: 800px; 270 + max-width: 800px; 269 271 } 270 272 271 273 .dashboard h1 { 272 - margin-bottom: 0.5rem; 274 + margin-bottom: 0.5rem; 273 275 } 274 276 275 277 .dashboard p { 276 - color: var(--text-muted); 277 - margin-bottom: 2rem; 278 + color: var(--text-muted); 279 + margin-bottom: 2rem; 278 280 } 279 281 280 282 .quick-actions { 281 - display: flex; 282 - gap: 1rem; 283 - flex-wrap: wrap; 283 + display: flex; 284 + gap: 1rem; 285 + flex-wrap: wrap; 284 286 } 285 287 286 288 /* Publication */ 287 289 .publication { 288 - max-width: 800px; 290 + max-width: 800px; 289 291 } 290 292 291 293 .pub-details { 292 - background: var(--bg-secondary); 293 - padding: 1.5rem; 294 - border-radius: 8px; 295 - margin-bottom: 1.5rem; 294 + background: var(--bg-secondary); 295 + padding: 1.5rem; 296 + border-radius: 8px; 297 + margin-bottom: 1.5rem; 296 298 } 297 299 298 300 .pub-details h2 { 299 - margin-bottom: 0.5rem; 301 + margin-bottom: 0.5rem; 300 302 } 301 303 302 304 .pub-details .url { 303 - color: var(--text-muted); 304 - margin-bottom: 0.5rem; 305 + color: var(--text-muted); 306 + margin-bottom: 0.5rem; 305 307 } 306 308 307 309 .pub-details .description { 308 - margin: 0; 310 + margin: 0; 309 311 } 310 312 311 313 /* Documents */ 312 314 .documents { 313 - max-width: 800px; 315 + max-width: 800px; 314 316 } 315 317 316 318 .documents-header { 317 - display: flex; 318 - justify-content: space-between; 319 - align-items: center; 320 - margin-bottom: 1.5rem; 319 + display: flex; 320 + justify-content: space-between; 321 + align-items: center; 322 + margin-bottom: 1.5rem; 321 323 } 322 324 323 325 .filters { 324 - display: flex; 325 - gap: 1rem; 326 - margin-bottom: 1.5rem; 327 - border-bottom: 1px solid var(--border); 328 - padding-bottom: 1rem; 326 + display: flex; 327 + gap: 1rem; 328 + margin-bottom: 1.5rem; 329 + border-bottom: 1px solid var(--border); 330 + padding-bottom: 1rem; 329 331 } 330 332 331 333 .filter { 332 - color: var(--text-muted); 333 - padding: 0.25rem 0; 334 + color: var(--text-muted); 335 + padding: 0.25rem 0; 334 336 } 335 337 336 338 .filter.active { 337 - color: var(--primary); 338 - border-bottom: 2px solid var(--primary); 339 + color: var(--primary); 340 + border-bottom: 2px solid var(--primary); 339 341 } 340 342 341 343 .document-list { 342 - list-style: none; 344 + list-style: none; 343 345 } 344 346 345 347 .document-item { 346 - border: 1px solid var(--border); 347 - border-radius: 4px; 348 - margin-bottom: 0.5rem; 348 + border: 1px solid var(--border); 349 + border-radius: 4px; 350 + margin-bottom: 0.5rem; 349 351 } 350 352 351 353 .document-item a { 352 - display: flex; 353 - justify-content: space-between; 354 - align-items: center; 355 - padding: 1rem; 356 - color: var(--text); 354 + display: flex; 355 + justify-content: space-between; 356 + align-items: center; 357 + padding: 1rem; 358 + color: var(--text); 357 359 } 358 360 359 361 .document-item a:hover { 360 - background: var(--bg-secondary); 361 - text-decoration: none; 362 + background: var(--bg-secondary); 363 + text-decoration: none; 362 364 } 363 365 364 366 .document-item .title { 365 - font-weight: 500; 367 + font-weight: 500; 366 368 } 367 369 368 370 .document-item .meta { 369 - display: flex; 370 - gap: 1rem; 371 - align-items: center; 371 + display: flex; 372 + gap: 1rem; 373 + align-items: center; 372 374 } 373 375 374 376 .document-item .date { 375 - color: var(--text-muted); 376 - font-size: 0.9rem; 377 + color: var(--text-muted); 378 + font-size: 0.9rem; 377 379 } 378 380 379 381 /* Badges */ 380 382 .badge { 381 - display: inline-block; 382 - padding: 0.25rem 0.5rem; 383 - border-radius: 4px; 384 - font-size: 0.75rem; 385 - font-weight: 600; 386 - text-transform: uppercase; 383 + display: inline-block; 384 + padding: 0.25rem 0.5rem; 385 + border-radius: 4px; 386 + font-size: 0.75rem; 387 + font-weight: 600; 388 + text-transform: uppercase; 387 389 } 388 390 389 391 .badge-draft { 390 - background: var(--draft); 391 - color: white; 392 + background: var(--draft); 393 + color: white; 392 394 } 393 395 394 396 .badge-published { 395 - background: var(--success); 396 - color: white; 397 + background: var(--success); 398 + color: white; 397 399 } 398 400 399 401 /* Document view */ 400 402 .document-view { 401 - max-width: 800px; 403 + max-width: 800px; 402 404 } 403 405 404 406 .document-header { 405 - margin-bottom: 1.5rem; 407 + margin-bottom: 1.5rem; 406 408 } 407 409 408 410 .document-header h1 { 409 - margin-bottom: 0.5rem; 411 + margin-bottom: 0.5rem; 410 412 } 411 413 412 414 .document-meta { 413 - display: flex; 414 - gap: 1rem; 415 - align-items: center; 416 - flex-wrap: wrap; 417 - color: var(--text-muted); 418 - font-size: 0.9rem; 415 + display: flex; 416 + gap: 1rem; 417 + align-items: center; 418 + flex-wrap: wrap; 419 + color: var(--text-muted); 420 + font-size: 0.9rem; 419 421 } 420 422 421 423 .document-content { 422 - background: var(--bg-secondary); 423 - padding: 1.5rem; 424 - border-radius: 8px; 425 - margin-bottom: 1.5rem; 424 + background: var(--bg-secondary); 425 + padding: 1.5rem; 426 + border-radius: 8px; 427 + margin-bottom: 1.5rem; 426 428 } 427 429 428 430 .document-content pre { 429 - white-space: pre-wrap; 430 - word-break: break-word; 431 - font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, monospace; 432 - font-size: 0.9rem; 433 - line-height: 1.7; 431 + white-space: pre-wrap; 432 + word-break: break-word; 433 + font-family: 434 + ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Monaco, monospace; 435 + font-size: 0.9rem; 436 + line-height: 1.7; 434 437 } 435 438 436 439 .actions { 437 - display: flex; 438 - gap: 1rem; 439 - flex-wrap: wrap; 440 + display: flex; 441 + gap: 1rem; 442 + flex-wrap: wrap; 440 443 } 441 444 442 445 .empty { 443 - color: var(--text-muted); 444 - text-align: center; 445 - padding: 3rem; 446 + color: var(--text-muted); 447 + text-align: center; 448 + padding: 3rem; 446 449 } 447 450 448 451 .error { 449 - color: var(--danger); 450 - text-align: center; 451 - padding: 2rem; 452 + color: var(--danger); 453 + text-align: center; 454 + padding: 2rem; 452 455 } 453 456 454 457 .error-message { 455 - background: #fef2f2; 456 - border: 1px solid #fecaca; 457 - color: #dc2626; 458 - padding: 1rem; 459 - border-radius: 4px; 460 - margin-bottom: 1.5rem; 458 + background: #fef2f2; 459 + border: 1px solid #fecaca; 460 + color: #dc2626; 461 + padding: 1rem; 462 + border-radius: 4px; 463 + margin-bottom: 1.5rem; 461 464 } 462 465 463 466 @media (prefers-color-scheme: dark) { 464 - .error-message { 465 - background: #450a0a; 466 - border-color: #7f1d1d; 467 - color: #fca5a5; 468 - } 467 + .error-message { 468 + background: #450a0a; 469 + border-color: #7f1d1d; 470 + color: #fca5a5; 471 + } 469 472 }
+18 -18
packages/web/scripts/cleanup.ts
··· 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 }
+22 -20
packages/web/src/lib/content-types.ts
··· 1 1 export interface ContentMarkdown { 2 - $type: "site.standard.content.markdown"; 3 - text: string; 2 + $type: "site.standard.content.markdown"; 3 + text: string; 4 4 } 5 5 6 6 export type DocumentContent = ContentMarkdown; 7 7 8 8 export function createMarkdownContent(text: string): ContentMarkdown { 9 - return { 10 - $type: "site.standard.content.markdown", 11 - text, 12 - }; 9 + return { 10 + $type: "site.standard.content.markdown", 11 + text, 12 + }; 13 13 } 14 14 15 15 export function isMarkdownContent(value: unknown): value is ContentMarkdown { 16 - return ( 17 - typeof value === "object" && 18 - value !== null && 19 - "$type" in value && 20 - (value as ContentMarkdown).$type === "site.standard.content.markdown" 21 - ); 16 + return ( 17 + typeof value === "object" && 18 + value !== null && 19 + "$type" in value && 20 + (value as ContentMarkdown).$type === "site.standard.content.markdown" 21 + ); 22 22 } 23 23 24 - export function getDocumentContentText(doc: Record<string, unknown>): string | null { 25 - if (isMarkdownContent(doc.content)) { 26 - return doc.content.text; 27 - } 28 - if (typeof doc.textContent === "string") { 29 - return doc.textContent; 30 - } 31 - return null; 24 + export function getDocumentContentText( 25 + doc: Record<string, unknown>, 26 + ): string | null { 27 + if (isMarkdownContent(doc.content)) { 28 + return doc.content.text; 29 + } 30 + if (typeof doc.textContent === "string") { 31 + return doc.textContent; 32 + } 33 + return null; 32 34 }
+61 -57
packages/web/src/lib/csrf.ts
··· 1 - import type { Context, Next } from 'hono'; 2 - import { getCookie, setCookie } from 'hono/cookie'; 1 + import type { Context, Next } from "hono"; 2 + import { getCookie, setCookie } from "hono/cookie"; 3 3 4 - const CSRF_COOKIE_NAME = 'csrf_token'; 5 - const CSRF_HEADER_NAME = 'x-csrf-token'; 6 - const CSRF_FORM_FIELD = '_csrf'; 4 + const CSRF_COOKIE_NAME = "csrf_token"; 5 + const CSRF_HEADER_NAME = "x-csrf-token"; 6 + const CSRF_FORM_FIELD = "_csrf"; 7 7 8 8 /** 9 9 * Generate a cryptographically secure random token 10 10 */ 11 11 function generateToken(): string { 12 - const buffer = new Uint8Array(32); 13 - crypto.getRandomValues(buffer); 14 - return Array.from(buffer, b => b.toString(16).padStart(2, '0')).join(''); 12 + const buffer = new Uint8Array(32); 13 + crypto.getRandomValues(buffer); 14 + return Array.from(buffer, (b) => b.toString(16).padStart(2, "0")).join(""); 15 15 } 16 16 17 17 /** 18 18 * Get or create a CSRF token for the current session 19 19 */ 20 20 export function getCSRFToken(c: Context): string { 21 - let token = getCookie(c, CSRF_COOKIE_NAME); 22 - 23 - if (!token) { 24 - token = generateToken(); 25 - setCookie(c, CSRF_COOKIE_NAME, token, { 26 - httpOnly: true, 27 - secure: process.env.PUBLIC_URL?.startsWith('https') || false, 28 - sameSite: 'Strict', 29 - path: '/', 30 - maxAge: 60 * 60 * 24, // 24 hours 31 - }); 32 - } 33 - 34 - return token; 21 + let token = getCookie(c, CSRF_COOKIE_NAME); 22 + 23 + if (!token) { 24 + token = generateToken(); 25 + setCookie(c, CSRF_COOKIE_NAME, token, { 26 + httpOnly: true, 27 + secure: process.env.PUBLIC_URL?.startsWith("https") || false, 28 + sameSite: "Strict", 29 + path: "/", 30 + maxAge: 60 * 60 * 24, // 24 hours 31 + }); 32 + } 33 + 34 + return token; 35 35 } 36 36 37 37 /** 38 38 * Middleware to validate CSRF token on POST/PUT/DELETE requests 39 39 */ 40 40 export async function csrfProtection(c: Context, next: Next) { 41 - const method = c.req.method.toUpperCase(); 42 - 43 - // Only check CSRF for state-changing methods 44 - if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) { 45 - const cookieToken = getCookie(c, CSRF_COOKIE_NAME); 46 - 47 - if (!cookieToken) { 48 - return c.text('CSRF token missing', 403); 49 - } 50 - 51 - // Check header first (for AJAX requests) 52 - let requestToken = c.req.header(CSRF_HEADER_NAME); 53 - 54 - // Fall back to form field 55 - if (!requestToken) { 56 - const contentType = c.req.header('content-type') || ''; 57 - if (contentType.includes('application/x-www-form-urlencoded') || 58 - contentType.includes('multipart/form-data')) { 59 - try { 60 - const body = await c.req.parseBody(); 61 - requestToken = body[CSRF_FORM_FIELD] as string; 62 - } catch { 63 - // Body might have already been parsed 64 - } 65 - } 66 - } 67 - 68 - if (!requestToken || requestToken !== cookieToken) { 69 - return c.text('CSRF token invalid', 403); 70 - } 71 - } 72 - 73 - await next(); 41 + const method = c.req.method.toUpperCase(); 42 + 43 + // Only check CSRF for state-changing methods 44 + if (["POST", "PUT", "DELETE", "PATCH"].includes(method)) { 45 + const cookieToken = getCookie(c, CSRF_COOKIE_NAME); 46 + 47 + if (!cookieToken) { 48 + return c.text("CSRF token missing", 403); 49 + } 50 + 51 + // Check header first (for AJAX requests) 52 + let requestToken = c.req.header(CSRF_HEADER_NAME); 53 + 54 + // Fall back to form field 55 + if (!requestToken) { 56 + const contentType = c.req.header("content-type") || ""; 57 + if ( 58 + contentType.includes("application/x-www-form-urlencoded") || 59 + contentType.includes("multipart/form-data") 60 + ) { 61 + try { 62 + const body = await c.req.parseBody(); 63 + requestToken = body[CSRF_FORM_FIELD] as string; 64 + } catch { 65 + // Body might have already been parsed 66 + } 67 + } 68 + } 69 + 70 + if (!requestToken || requestToken !== cookieToken) { 71 + return c.text("CSRF token invalid", 403); 72 + } 73 + } 74 + 75 + await next(); 74 76 } 75 77 76 - import { raw } from 'hono/html'; 78 + import { raw } from "hono/html"; 77 79 78 80 /** 79 81 * HTML helper to generate a hidden CSRF input field 80 82 * Returns a raw HTML string that won't be escaped by Hono's html template 81 83 */ 82 84 export function csrfField(token: string) { 83 - return raw(`<input type="hidden" name="${CSRF_FORM_FIELD}" value="${token}" />`); 85 + return raw( 86 + `<input type="hidden" name="${CSRF_FORM_FIELD}" value="${token}" />`, 87 + ); 84 88 }
+40 -33
packages/web/src/lib/logger.ts
··· 1 - import * as fs from 'fs'; 2 - import * as path from 'path'; 1 + import * as fs from "fs"; 2 + import * as path from "path"; 3 3 4 - const DATA_DIR = process.env.DATA_DIR || './data'; 5 - const LOG_PATH = path.join(DATA_DIR, 'app.log'); 4 + const DATA_DIR = process.env.DATA_DIR || "./data"; 5 + const LOG_PATH = path.join(DATA_DIR, "app.log"); 6 6 7 - type LogLevel = 'INFO' | 'WARN' | 'ERROR' | 'DEBUG'; 7 + type LogLevel = "INFO" | "WARN" | "ERROR" | "DEBUG"; 8 8 9 9 function formatDate(date: Date): string { 10 - return date.toISOString(); 10 + return date.toISOString(); 11 11 } 12 12 13 - function writeLog(level: LogLevel, message: string, meta?: Record<string, any>) { 14 - const timestamp = formatDate(new Date()); 15 - const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''; 16 - const logLine = `[${timestamp}] ${level}: ${message}${metaStr}\n`; 17 - 18 - // Write to file 19 - try { 20 - fs.appendFileSync(LOG_PATH, logLine); 21 - } catch (err) { 22 - // Fall back to console if file write fails 23 - console.error('Failed to write to log file:', err); 24 - } 25 - 26 - // Also write to stdout/stderr for systemd journal 27 - if (level === 'ERROR') { 28 - process.stderr.write(logLine); 29 - } else { 30 - process.stdout.write(logLine); 31 - } 13 + function writeLog( 14 + level: LogLevel, 15 + message: string, 16 + meta?: Record<string, any>, 17 + ) { 18 + const timestamp = formatDate(new Date()); 19 + const metaStr = meta ? ` ${JSON.stringify(meta)}` : ""; 20 + const logLine = `[${timestamp}] ${level}: ${message}${metaStr}\n`; 21 + 22 + // Write to file 23 + try { 24 + fs.appendFileSync(LOG_PATH, logLine); 25 + } catch (err) { 26 + // Fall back to console if file write fails 27 + console.error("Failed to write to log file:", err); 28 + } 29 + 30 + // Also write to stdout/stderr for systemd journal 31 + if (level === "ERROR") { 32 + process.stderr.write(logLine); 33 + } else { 34 + process.stdout.write(logLine); 35 + } 32 36 } 33 37 34 38 export const logger = { 35 - info: (message: string, meta?: Record<string, any>) => writeLog('INFO', message, meta), 36 - warn: (message: string, meta?: Record<string, any>) => writeLog('WARN', message, meta), 37 - error: (message: string, meta?: Record<string, any>) => writeLog('ERROR', message, meta), 38 - debug: (message: string, meta?: Record<string, any>) => { 39 - if (process.env.DEBUG) { 40 - writeLog('DEBUG', message, meta); 41 - } 42 - }, 39 + info: (message: string, meta?: Record<string, any>) => 40 + writeLog("INFO", message, meta), 41 + warn: (message: string, meta?: Record<string, any>) => 42 + writeLog("WARN", message, meta), 43 + error: (message: string, meta?: Record<string, any>) => 44 + writeLog("ERROR", message, meta), 45 + debug: (message: string, meta?: Record<string, any>) => { 46 + if (process.env.DEBUG) { 47 + writeLog("DEBUG", message, meta); 48 + } 49 + }, 43 50 };
+94 -94
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"; ··· 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: "std.pub", 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: "std.pub", 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 }
+25 -25
packages/web/src/lib/session.ts
··· 1 - import type { Context } from 'hono'; 2 - import { getCookie } from 'hono/cookie'; 3 - import { getAgentForSession } from './oauth'; 4 - import type { Agent } from '@atproto/api'; 1 + import type { Context } from "hono"; 2 + import { getCookie } from "hono/cookie"; 3 + import { getAgentForSession } from "./oauth"; 4 + import type { Agent } from "@atproto/api"; 5 5 6 6 export interface Session { 7 - did: string | null; 8 - handle: string | null; 9 - agent: Agent | null; 7 + did: string | null; 8 + handle: string | null; 9 + agent: Agent | null; 10 10 } 11 11 12 12 export async function getSession(c: Context): Promise<Session> { 13 - const did = getCookie(c, 'session'); 14 - 15 - if (!did) { 16 - return { did: null, handle: null, agent: null }; 17 - } 13 + const did = getCookie(c, "session"); 18 14 19 - try { 20 - const { agent, handle } = await getAgentForSession(did); 21 - return { did, handle, agent }; 22 - } catch (error) { 23 - // Session might be invalid or expired 24 - console.error('Session error:', error); 25 - return { did: null, handle: null, agent: null }; 26 - } 15 + if (!did) { 16 + return { did: null, handle: null, agent: null }; 17 + } 18 + 19 + try { 20 + const { agent, handle } = await getAgentForSession(did); 21 + return { did, handle, agent }; 22 + } catch (error) { 23 + // Session might be invalid or expired 24 + console.error("Session error:", error); 25 + return { did: null, handle: null, agent: null }; 26 + } 27 27 } 28 28 29 29 export function requireAuth(c: Context): Session { 30 - const session = c.get('session') as Session; 31 - if (!session.did || !session.agent) { 32 - throw new Error('Not authenticated'); 33 - } 34 - return session; 30 + const session = c.get("session") as Session; 31 + if (!session.did || !session.agent) { 32 + throw new Error("Not authenticated"); 33 + } 34 + return session; 35 35 }
+11 -11
packages/web/src/lib/validation.ts
··· 3 3 * TIDs are base36 encoded and should be 13 characters 4 4 */ 5 5 export function isValidTID(tid: string): boolean { 6 - if (!tid || typeof tid !== 'string') return false; 7 - // TID should be 13 characters of base36 (0-9, a-z) 8 - return /^[0-9a-z]{13}$/.test(tid); 6 + if (!tid || typeof tid !== "string") return false; 7 + // TID should be 13 characters of base36 (0-9, a-z) 8 + return /^[0-9a-z]{13}$/.test(tid); 9 9 } 10 10 11 11 /** 12 12 * Validate that a URL is a valid HTTPS URL 13 13 */ 14 14 export function isValidHttpsUrl(url: string): boolean { 15 - try { 16 - const parsed = new URL(url); 17 - return parsed.protocol === 'https:'; 18 - } catch { 19 - return false; 20 - } 15 + try { 16 + const parsed = new URL(url); 17 + return parsed.protocol === "https:"; 18 + } catch { 19 + return false; 20 + } 21 21 } 22 22 23 23 /** ··· 25 25 * Note: Hono's html template already escapes, but this is defense in depth 26 26 */ 27 27 export function sanitizeString(str: string, maxLength: number = 1000): string { 28 - if (!str || typeof str !== 'string') return ''; 29 - return str.slice(0, maxLength); 28 + if (!str || typeof str !== "string") return ""; 29 + return str.slice(0, maxLength); 30 30 }
+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"; ··· 14 14 15 15 // Client metadata endpoint (required for OAuth) 16 16 authRoutes.get("/client-metadata.json", async (c) => { 17 - try { 18 - const metadata = await getClientMetadata(); 19 - return c.json(metadata); 20 - } catch (error) { 21 - console.error("Error getting client metadata:", error); 22 - return c.json({ error: "Failed to get client metadata" }, 500); 23 - } 17 + try { 18 + const metadata = await getClientMetadata(); 19 + return c.json(metadata); 20 + } catch (error) { 21 + console.error("Error getting client metadata:", error); 22 + return c.json({ error: "Failed to get client metadata" }, 500); 23 + } 24 24 }); 25 25 26 26 // JWKS endpoint (required for confidential clients) 27 27 authRoutes.get("/jwks.json", async (c) => { 28 - try { 29 - const jwks = await getJwks(); 30 - return c.json(jwks); 31 - } catch (error) { 32 - console.error("Error getting JWKS:", error); 33 - return c.json({ error: "Failed to get JWKS" }, 500); 34 - } 28 + try { 29 + const jwks = await getJwks(); 30 + return c.json(jwks); 31 + } catch (error) { 32 + console.error("Error getting JWKS:", error); 33 + return c.json({ error: "Failed to get JWKS" }, 500); 34 + } 35 35 }); 36 36 37 37 // Login page 38 38 authRoutes.get("/login", async (c) => { 39 - const error = c.req.query("error"); 40 - const csrfToken = c.get("csrfToken") as string; 39 + const error = c.req.query("error"); 40 + const csrfToken = c.get("csrfToken") as string; 41 41 42 - const content = html` 42 + const content = html` 43 43 <div class="auth-form"> 44 44 <h1>Login with Bluesky</h1> 45 45 46 - ${error 47 - ? html` 46 + ${ 47 + error 48 + ? html` 48 49 <div class="error-message"> 49 - ${error === "handle_required" 50 - ? "Please enter your handle or DID." 51 - : error === "authorization_failed" 52 - ? "Authorization failed. Please try again." 53 - : error === "callback_failed" 54 - ? "Login failed. Please try again." 55 - : "An error occurred. Please try again."} 50 + ${ 51 + error === "handle_required" 52 + ? "Please enter your handle or DID." 53 + : error === "authorization_failed" 54 + ? "Authorization failed. Please try again." 55 + : error === "callback_failed" 56 + ? "Login failed. Please try again." 57 + : "An error occurred. Please try again." 58 + } 56 59 </div> 57 60 ` 58 - : ""} 61 + : "" 62 + } 59 63 60 64 <form action="/auth/login" method="POST"> 61 65 ${csrfField(csrfToken)} ··· 80 84 </div> 81 85 `; 82 86 83 - return c.html(layout(content, { title: "Login - std.pub" })); 87 + return c.html(layout(content, { title: "Login - std.pub" })); 84 88 }); 85 89 86 90 // Handle login form submission 87 91 authRoutes.post("/login", async (c) => { 88 - const body = await c.req.parseBody(); 89 - let handle = body.handle as string; 92 + const body = await c.req.parseBody(); 93 + let handle = body.handle as string; 90 94 91 - if (!handle) { 92 - return c.redirect("/auth/login?error=handle_required"); 93 - } 95 + if (!handle) { 96 + return c.redirect("/auth/login?error=handle_required"); 97 + } 94 98 95 - // Trim and normalize handle 96 - handle = handle.trim().toLowerCase(); 99 + // Trim and normalize handle 100 + handle = handle.trim().toLowerCase(); 97 101 98 - // Remove @ prefix if present 99 - if (handle.startsWith("@")) { 100 - handle = handle.slice(1); 101 - } 102 + // Remove @ prefix if present 103 + if (handle.startsWith("@")) { 104 + handle = handle.slice(1); 105 + } 102 106 103 - try { 104 - const client = await getOAuthClient(); 105 - const url = await client.authorize(handle, { 106 - scope: "atproto transition:generic", 107 - }); 107 + try { 108 + const client = await getOAuthClient(); 109 + const url = await client.authorize(handle, { 110 + scope: "atproto transition:generic", 111 + }); 108 112 109 - return c.redirect(url.toString()); 110 - } catch (error) { 111 - console.error("Login error:", error); 112 - return c.redirect("/auth/login?error=authorization_failed"); 113 - } 113 + return c.redirect(url.toString()); 114 + } catch (error) { 115 + console.error("Login error:", error); 116 + return c.redirect("/auth/login?error=authorization_failed"); 117 + } 114 118 }); 115 119 116 120 // OAuth callback 117 121 authRoutes.get("/callback", async (c) => { 118 - const url = new URL(c.req.url); 119 - const params = url.searchParams; 122 + const url = new URL(c.req.url); 123 + const params = url.searchParams; 120 124 121 - // Check for error from authorization server 122 - const error = params.get("error"); 123 - if (error) { 124 - console.error("OAuth error:", error, params.get("error_description")); 125 - return c.redirect("/auth/login?error=callback_failed"); 126 - } 125 + // Check for error from authorization server 126 + const error = params.get("error"); 127 + if (error) { 128 + console.error("OAuth error:", error, params.get("error_description")); 129 + return c.redirect("/auth/login?error=callback_failed"); 130 + } 127 131 128 - try { 129 - const client = await getOAuthClient(); 130 - const { session } = await client.callback(params); 132 + try { 133 + const client = await getOAuthClient(); 134 + const { session } = await client.callback(params); 131 135 132 - // Store the DID in a cookie for session management 133 - // The actual OAuth session is stored in the database by the OAuth client 134 - setCookie(c, "session", session.did, { 135 - httpOnly: true, 136 - secure: 137 - process.env.NODE_ENV === "production" || 138 - process.env.PUBLIC_URL?.startsWith("https"), 139 - sameSite: "Lax", 140 - maxAge: 60 * 60 * 24 * 7, // 7 days 141 - path: "/", 142 - }); 136 + // Store the DID in a cookie for session management 137 + // The actual OAuth session is stored in the database by the OAuth client 138 + setCookie(c, "session", session.did, { 139 + httpOnly: true, 140 + secure: 141 + process.env.NODE_ENV === "production" || 142 + process.env.PUBLIC_URL?.startsWith("https"), 143 + sameSite: "Lax", 144 + maxAge: 60 * 60 * 24 * 7, // 7 days 145 + path: "/", 146 + }); 143 147 144 - return c.redirect("/"); 145 - } catch (error) { 146 - console.error("Callback error:", error); 147 - return c.redirect("/auth/login?error=callback_failed"); 148 - } 148 + return c.redirect("/"); 149 + } catch (error) { 150 + console.error("Callback error:", error); 151 + return c.redirect("/auth/login?error=callback_failed"); 152 + } 149 153 }); 150 154 151 155 // Logout 152 156 authRoutes.get("/logout", async (c) => { 153 - const did = getCookie(c, "session"); 157 + const did = getCookie(c, "session"); 154 158 155 - if (did) { 156 - try { 157 - // Delete the OAuth session from the database 158 - await deleteSession(did); 159 - } catch (error) { 160 - console.error("Error deleting session:", error); 161 - } 162 - } 159 + if (did) { 160 + try { 161 + // Delete the OAuth session from the database 162 + await deleteSession(did); 163 + } catch (error) { 164 + console.error("Error deleting session:", error); 165 + } 166 + } 163 167 164 - deleteCookie(c, "session", { path: "/" }); 165 - return c.redirect("/"); 168 + deleteCookie(c, "session", { path: "/" }); 169 + return c.redirect("/"); 166 170 });
+376 -363
packages/web/src/routes/documents.ts
··· 4 4 import { requireAuth, type Session } from "../lib/session"; 5 5 import { csrfField } from "../lib/csrf"; 6 6 import { isValidTID } from "../lib/validation"; 7 - import { createMarkdownContent, getDocumentContentText } from "../lib/content-types"; 7 + import { 8 + createMarkdownContent, 9 + getDocumentContentText, 10 + } from "../lib/content-types"; 8 11 import { marked } from "marked"; 9 12 10 13 export const documentRoutes = new Hono(); ··· 14 17 15 18 // List all documents 16 19 documentRoutes.get("/", async (c) => { 17 - let session: Session; 18 - try { 19 - session = requireAuth(c); 20 - } catch { 21 - return c.redirect("/auth/login"); 22 - } 20 + let session: Session; 21 + try { 22 + session = requireAuth(c); 23 + } catch { 24 + return c.redirect("/auth/login"); 25 + } 23 26 24 - const filter = c.req.query("filter") || "all"; 27 + const filter = c.req.query("filter") || "all"; 25 28 26 - try { 27 - const response = await session.agent!.com.atproto.repo.listRecords({ 28 - repo: session.did!, 29 - collection: DOCUMENT_COLLECTION, 30 - limit: 100, 31 - }); 29 + try { 30 + const response = await session.agent!.com.atproto.repo.listRecords({ 31 + repo: session.did!, 32 + collection: DOCUMENT_COLLECTION, 33 + limit: 100, 34 + }); 32 35 33 - let documents = response.data.records; 36 + let documents = response.data.records; 34 37 35 - // Filter by draft/published status 36 - if (filter === "drafts") { 37 - documents = documents.filter((doc: any) => { 38 - const tags = doc.value.tags || []; 39 - return tags.includes("draft"); 40 - }); 41 - } else if (filter === "published") { 42 - documents = documents.filter((doc: any) => { 43 - const tags = doc.value.tags || []; 44 - return !tags.includes("draft"); 45 - }); 46 - } 38 + // Filter by draft/published status 39 + if (filter === "drafts") { 40 + documents = documents.filter((doc: any) => { 41 + const tags = doc.value.tags || []; 42 + return tags.includes("draft"); 43 + }); 44 + } else if (filter === "published") { 45 + documents = documents.filter((doc: any) => { 46 + const tags = doc.value.tags || []; 47 + return !tags.includes("draft"); 48 + }); 49 + } 47 50 48 - // Sort by publishedAt or updatedAt 49 - documents.sort((a: any, b: any) => { 50 - const dateA = new Date( 51 - a.value.updatedAt || a.value.publishedAt, 52 - ).getTime(); 53 - const dateB = new Date( 54 - b.value.updatedAt || b.value.publishedAt, 55 - ).getTime(); 56 - return dateB - dateA; 57 - }); 51 + // Sort by publishedAt or updatedAt 52 + documents.sort((a: any, b: any) => { 53 + const dateA = new Date( 54 + a.value.updatedAt || a.value.publishedAt, 55 + ).getTime(); 56 + const dateB = new Date( 57 + b.value.updatedAt || b.value.publishedAt, 58 + ).getTime(); 59 + return dateB - dateA; 60 + }); 58 61 59 - const content = html` 62 + const content = html` 60 63 <div class="documents"> 61 64 <div class="documents-header"> 62 65 <h1>Documents</h1> ··· 81 84 > 82 85 </div> 83 86 84 - ${documents.length === 0 85 - ? html` 87 + ${ 88 + documents.length === 0 89 + ? html` 86 90 <p class="empty"> 87 91 No documents yet. 88 92 <a href="/documents/new">Create your first document</a>. 89 93 </p> 90 94 ` 91 - : html` 95 + : html` 92 96 <ul class="document-list"> 93 97 ${documents.map((doc: any) => { 94 - const rkey = doc.uri.split("/").pop(); 95 - const value = doc.value; 96 - const isDraft = (value.tags || []).includes("draft"); 97 - const date = value.publishedAt 98 - ? new Date(value.publishedAt).toLocaleDateString() 99 - : ""; 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 + : ""; 100 104 101 - return html` 105 + return html` 102 106 <li 103 107 class="document-item ${isDraft ? "draft" : "published"}" 104 108 > 105 109 <a href="/documents/${rkey}"> 106 110 <span class="title">${value.title}</span> 107 111 <span class="meta"> 108 - ${isDraft 109 - ? html`<span class="badge badge-draft">Draft</span>` 110 - : ""} 112 + ${ 113 + isDraft 114 + ? html`<span class="badge badge-draft">Draft</span>` 115 + : "" 116 + } 111 117 <span class="date">${date}</span> 112 118 </span> 113 119 </a> 114 120 </li> 115 121 `; 116 - })} 122 + })} 117 123 </ul> 118 - `} 124 + ` 125 + } 119 126 </div> 120 127 `; 121 128 122 - return c.html(layout(content, { title: "Documents - std.pub", session })); 123 - } catch (error) { 124 - console.error("Error fetching documents:", error); 125 - const content = html`<p class="error"> 129 + return c.html(layout(content, { title: "Documents - std.pub", session })); 130 + } catch (error) { 131 + console.error("Error fetching documents:", error); 132 + const content = html`<p class="error"> 126 133 Error loading documents. Please try again. 127 134 </p>`; 128 - return c.html(layout(content, { title: "Documents - std.pub", session })); 129 - } 135 + return c.html(layout(content, { title: "Documents - std.pub", session })); 136 + } 130 137 }); 131 138 132 139 // New document form 133 140 documentRoutes.get("/new", async (c) => { 134 - let session: Session; 135 - try { 136 - session = requireAuth(c); 137 - } catch { 138 - return c.redirect("/auth/login"); 139 - } 141 + let session: Session; 142 + try { 143 + session = requireAuth(c); 144 + } catch { 145 + return c.redirect("/auth/login"); 146 + } 140 147 141 - // Get publication to use as site reference 142 - let publicationUri = ""; 143 - try { 144 - const response = await session.agent!.com.atproto.repo.listRecords({ 145 - repo: session.did!, 146 - collection: PUBLICATION_COLLECTION, 147 - limit: 1, 148 - }); 149 - if (response.data.records[0]) { 150 - publicationUri = response.data.records[0].uri; 151 - } 152 - } catch (e) { 153 - // No publication yet, will need URL 154 - } 148 + // Get publication to use as site reference 149 + let publicationUri = ""; 150 + try { 151 + const response = await session.agent!.com.atproto.repo.listRecords({ 152 + repo: session.did!, 153 + collection: PUBLICATION_COLLECTION, 154 + limit: 1, 155 + }); 156 + if (response.data.records[0]) { 157 + publicationUri = response.data.records[0].uri; 158 + } 159 + } catch (e) { 160 + // No publication yet, will need URL 161 + } 155 162 156 - const csrfToken = c.get("csrfToken") as string; 163 + const csrfToken = c.get("csrfToken") as string; 157 164 158 - const content = html` 165 + const content = html` 159 166 <div class="form-page"> 160 167 <h1>New Document</h1> 161 168 ··· 242 249 </script> 243 250 `; 244 251 245 - return c.html(layout(content, { title: "New Document - std.pub", session })); 252 + return c.html(layout(content, { title: "New Document - std.pub", session })); 246 253 }); 247 254 248 255 // Handle document creation 249 256 documentRoutes.post("/new", async (c) => { 250 - let session: Session; 251 - try { 252 - session = requireAuth(c); 253 - } catch { 254 - return c.redirect("/auth/login"); 255 - } 257 + let session: Session; 258 + try { 259 + session = requireAuth(c); 260 + } catch { 261 + return c.redirect("/auth/login"); 262 + } 256 263 257 - const body = await c.req.parseBody(); 258 - const title = body.title as string; 259 - const path = (body.path as string) || undefined; 260 - const description = (body.description as string) || undefined; 261 - const content = (body.content as string) || undefined; 262 - const tagsStr = (body.tags as string) || ""; 263 - const action = body.action as string; 264 - const publicationUri = body.publicationUri as string; 264 + const body = await c.req.parseBody(); 265 + const title = body.title as string; 266 + const path = (body.path as string) || undefined; 267 + const description = (body.description as string) || undefined; 268 + const content = (body.content as string) || undefined; 269 + const tagsStr = (body.tags as string) || ""; 270 + const action = body.action as string; 271 + const publicationUri = body.publicationUri as string; 265 272 266 - // Parse tags 267 - let tags = tagsStr 268 - .split(",") 269 - .map((t) => t.trim()) 270 - .filter((t) => t); 273 + // Parse tags 274 + let tags = tagsStr 275 + .split(",") 276 + .map((t) => t.trim()) 277 + .filter((t) => t); 271 278 272 - // If publishing, remove draft tag 273 - if (action === "publish") { 274 - tags = tags.filter((t) => t !== "draft"); 275 - } else if (!tags.includes("draft")) { 276 - tags.push("draft"); 277 - } 279 + // If publishing, remove draft tag 280 + if (action === "publish") { 281 + tags = tags.filter((t) => t !== "draft"); 282 + } else if (!tags.includes("draft")) { 283 + tags.push("draft"); 284 + } 278 285 279 - const now = new Date().toISOString(); 286 + const now = new Date().toISOString(); 280 287 281 - try { 282 - const rkey = generateTID(); 288 + try { 289 + const rkey = generateTID(); 283 290 284 - // Determine site reference 285 - let site = publicationUri; 286 - if (!site) { 287 - // Fall back to a URL if no publication 288 - site = `https://${session.handle}.bsky.social`; 289 - } 291 + // Determine site reference 292 + let site = publicationUri; 293 + if (!site) { 294 + // Fall back to a URL if no publication 295 + site = `https://${session.handle}.bsky.social`; 296 + } 290 297 291 - const record: Record<string, any> = { 292 - $type: DOCUMENT_COLLECTION, 293 - title, 294 - site, 295 - publishedAt: action === "publish" ? now : now, 296 - updatedAt: now, 297 - }; 298 + const record: Record<string, any> = { 299 + $type: DOCUMENT_COLLECTION, 300 + title, 301 + site, 302 + publishedAt: action === "publish" ? now : now, 303 + updatedAt: now, 304 + }; 298 305 299 - if (path) record.path = path.startsWith("/") ? path : `/${path}`; 300 - if (description) record.description = description; 301 - if (content) { 302 - record.content = createMarkdownContent(content); 303 - record.textContent = content; 304 - } 305 - if (tags.length > 0) record.tags = tags; 306 + if (path) record.path = path.startsWith("/") ? path : `/${path}`; 307 + if (description) record.description = description; 308 + if (content) { 309 + record.content = createMarkdownContent(content); 310 + record.textContent = content; 311 + } 312 + if (tags.length > 0) record.tags = tags; 306 313 307 - await session.agent!.com.atproto.repo.createRecord({ 308 - repo: session.did!, 309 - collection: DOCUMENT_COLLECTION, 310 - rkey, 311 - record, 312 - }); 314 + await session.agent!.com.atproto.repo.createRecord({ 315 + repo: session.did!, 316 + collection: DOCUMENT_COLLECTION, 317 + rkey, 318 + record, 319 + }); 313 320 314 - return c.redirect(`/documents/${rkey}`); 315 - } catch (error) { 316 - console.error("Error creating document:", error); 317 - return c.redirect("/documents/new?error=create_failed"); 318 - } 321 + return c.redirect(`/documents/${rkey}`); 322 + } catch (error) { 323 + console.error("Error creating document:", error); 324 + return c.redirect("/documents/new?error=create_failed"); 325 + } 319 326 }); 320 327 321 328 // View single document 322 329 documentRoutes.get("/:rkey", async (c) => { 323 - let session: Session; 324 - try { 325 - session = requireAuth(c); 326 - } catch { 327 - return c.redirect("/auth/login"); 328 - } 330 + let session: Session; 331 + try { 332 + session = requireAuth(c); 333 + } catch { 334 + return c.redirect("/auth/login"); 335 + } 329 336 330 - const rkey = c.req.param("rkey"); 337 + const rkey = c.req.param("rkey"); 331 338 332 - // Validate rkey format 333 - if (!isValidTID(rkey)) { 334 - return c.redirect("/documents"); 335 - } 339 + // Validate rkey format 340 + if (!isValidTID(rkey)) { 341 + return c.redirect("/documents"); 342 + } 336 343 337 - try { 338 - const response = await session.agent!.com.atproto.repo.getRecord({ 339 - repo: session.did!, 340 - collection: DOCUMENT_COLLECTION, 341 - rkey, 342 - }); 344 + try { 345 + const response = await session.agent!.com.atproto.repo.getRecord({ 346 + repo: session.did!, 347 + collection: DOCUMENT_COLLECTION, 348 + rkey, 349 + }); 343 350 344 - const doc = response.data.value as any; 345 - const isDraft = (doc.tags || []).includes("draft"); 346 - const csrfToken = c.get("csrfToken") as string; 351 + const doc = response.data.value as any; 352 + const isDraft = (doc.tags || []).includes("draft"); 353 + const csrfToken = c.get("csrfToken") as string; 347 354 348 - const content = html` 355 + const content = html` 349 356 <div class="document-view"> 350 357 <div class="document-header"> 351 358 <h1>${doc.title}</h1> 352 359 <div class="document-meta"> 353 - ${isDraft 354 - ? html`<span class="badge badge-draft">Draft</span>` 355 - : html`<span class="badge badge-published">Published</span>`} 356 - ${doc.publishedAt 357 - ? html`<span class="date" 360 + ${ 361 + isDraft 362 + ? html`<span class="badge badge-draft">Draft</span>` 363 + : html`<span class="badge badge-published">Published</span>` 364 + } 365 + ${ 366 + doc.publishedAt 367 + ? html`<span class="date" 358 368 >Published: 359 369 ${new Date(doc.publishedAt).toLocaleDateString()}</span 360 370 >` 361 - : ""} 371 + : "" 372 + } 362 373 ${doc.path ? html`<span class="path">Path: ${doc.path}</span>` : ""} 363 374 </div> 364 375 </div> 365 376 366 - ${doc.description 367 - ? html`<p class="description">${doc.description}</p>` 368 - : ""} 377 + ${ 378 + doc.description 379 + ? html`<p class="description">${doc.description}</p>` 380 + : "" 381 + } 369 382 370 383 <div class="document-content"> 371 - ${ 372 - (() => { 373 - const text = getDocumentContentText(doc); 374 - if (!text) return html`<p class="empty">(No content)</p>`; 375 - const htmlContent = marked.parse(text) as string; 376 - return html`<div class="markdown-body">${raw(htmlContent)}</div>`; 377 - })() 378 - } 384 + ${(() => { 385 + const text = getDocumentContentText(doc); 386 + if (!text) return html`<p class="empty">(No content)</p>`; 387 + const htmlContent = marked.parse(text) as string; 388 + return html`<div class="markdown-body">${raw(htmlContent)}</div>`; 389 + })()} 379 390 </div> 380 391 381 392 <div class="actions"> 382 393 <a href="/documents/${rkey}/edit" class="btn btn-primary">Edit</a> 383 - ${isDraft 384 - ? html` 394 + ${ 395 + isDraft 396 + ? html` 385 397 <form 386 398 action="/documents/${rkey}/publish" 387 399 method="POST" ··· 391 403 <button type="submit" class="btn btn-success">Publish</button> 392 404 </form> 393 405 ` 394 - : html` 406 + : html` 395 407 <form 396 408 action="/documents/${rkey}/unpublish" 397 409 method="POST" ··· 402 414 Unpublish 403 415 </button> 404 416 </form> 405 - `} 417 + ` 418 + } 406 419 <form 407 420 action="/documents/${rkey}/delete" 408 421 method="POST" ··· 417 430 </div> 418 431 `; 419 432 420 - return c.html( 421 - layout(content, { title: `${doc.title} - std.pub`, session }), 422 - ); 423 - } catch (error) { 424 - console.error("Error fetching document:", error); 425 - return c.redirect("/documents"); 426 - } 433 + return c.html( 434 + layout(content, { title: `${doc.title} - std.pub`, session }), 435 + ); 436 + } catch (error) { 437 + console.error("Error fetching document:", error); 438 + return c.redirect("/documents"); 439 + } 427 440 }); 428 441 429 442 // Edit document form 430 443 documentRoutes.get("/:rkey/edit", async (c) => { 431 - let session: Session; 432 - try { 433 - session = requireAuth(c); 434 - } catch { 435 - return c.redirect("/auth/login"); 436 - } 444 + let session: Session; 445 + try { 446 + session = requireAuth(c); 447 + } catch { 448 + return c.redirect("/auth/login"); 449 + } 437 450 438 - const rkey = c.req.param("rkey"); 451 + const rkey = c.req.param("rkey"); 439 452 440 - if (!isValidTID(rkey)) { 441 - return c.redirect("/documents"); 442 - } 453 + if (!isValidTID(rkey)) { 454 + return c.redirect("/documents"); 455 + } 443 456 444 - try { 445 - const response = await session.agent!.com.atproto.repo.getRecord({ 446 - repo: session.did!, 447 - collection: DOCUMENT_COLLECTION, 448 - rkey, 449 - }); 457 + try { 458 + const response = await session.agent!.com.atproto.repo.getRecord({ 459 + repo: session.did!, 460 + collection: DOCUMENT_COLLECTION, 461 + rkey, 462 + }); 450 463 451 - const doc = response.data.value as any; 452 - const csrfToken = c.get("csrfToken") as string; 464 + const doc = response.data.value as any; 465 + const csrfToken = c.get("csrfToken") as string; 453 466 454 - const content = html` 467 + const content = html` 455 468 <div class="form-page"> 456 469 <h1>Edit Document</h1> 457 470 ··· 525 538 </div> 526 539 `; 527 540 528 - return c.html( 529 - layout(content, { title: `Edit: ${doc.title} - std.pub`, session }), 530 - ); 531 - } catch (error) { 532 - console.error("Error fetching document:", error); 533 - return c.redirect("/documents"); 534 - } 541 + return c.html( 542 + layout(content, { title: `Edit: ${doc.title} - std.pub`, session }), 543 + ); 544 + } catch (error) { 545 + console.error("Error fetching document:", error); 546 + return c.redirect("/documents"); 547 + } 535 548 }); 536 549 537 550 // Handle document update 538 551 documentRoutes.post("/:rkey/edit", async (c) => { 539 - let session: Session; 540 - try { 541 - session = requireAuth(c); 542 - } catch { 543 - return c.redirect("/auth/login"); 544 - } 552 + let session: Session; 553 + try { 554 + session = requireAuth(c); 555 + } catch { 556 + return c.redirect("/auth/login"); 557 + } 545 558 546 - const rkey = c.req.param("rkey"); 559 + const rkey = c.req.param("rkey"); 547 560 548 - if (!isValidTID(rkey)) { 549 - return c.redirect("/documents"); 550 - } 561 + if (!isValidTID(rkey)) { 562 + return c.redirect("/documents"); 563 + } 551 564 552 - const body = await c.req.parseBody(); 565 + const body = await c.req.parseBody(); 553 566 554 - try { 555 - // Get existing record 556 - const existing = await session.agent!.com.atproto.repo.getRecord({ 557 - repo: session.did!, 558 - collection: DOCUMENT_COLLECTION, 559 - rkey, 560 - }); 567 + try { 568 + // Get existing record 569 + const existing = await session.agent!.com.atproto.repo.getRecord({ 570 + repo: session.did!, 571 + collection: DOCUMENT_COLLECTION, 572 + rkey, 573 + }); 561 574 562 - const oldDoc = existing.data.value as any; 575 + const oldDoc = existing.data.value as any; 563 576 564 - const title = body.title as string; 565 - const path = (body.path as string) || undefined; 566 - const description = (body.description as string) || undefined; 567 - const content = (body.content as string) || undefined; 568 - const tagsStr = (body.tags as string) || ""; 569 - const tags = tagsStr 570 - .split(",") 571 - .map((t) => t.trim()) 572 - .filter((t) => t); 577 + const title = body.title as string; 578 + const path = (body.path as string) || undefined; 579 + const description = (body.description as string) || undefined; 580 + const content = (body.content as string) || undefined; 581 + const tagsStr = (body.tags as string) || ""; 582 + const tags = tagsStr 583 + .split(",") 584 + .map((t) => t.trim()) 585 + .filter((t) => t); 573 586 574 - const record: Record<string, any> = { 575 - $type: DOCUMENT_COLLECTION, 576 - title, 577 - site: oldDoc.site, 578 - publishedAt: oldDoc.publishedAt, 579 - updatedAt: new Date().toISOString(), 580 - }; 587 + const record: Record<string, any> = { 588 + $type: DOCUMENT_COLLECTION, 589 + title, 590 + site: oldDoc.site, 591 + publishedAt: oldDoc.publishedAt, 592 + updatedAt: new Date().toISOString(), 593 + }; 581 594 582 - if (path) record.path = path.startsWith("/") ? path : `/${path}`; 583 - if (description) record.description = description; 584 - if (content) { 585 - record.content = createMarkdownContent(content); 586 - record.textContent = content; 587 - } 588 - if (tags.length > 0) record.tags = tags; 595 + if (path) record.path = path.startsWith("/") ? path : `/${path}`; 596 + if (description) record.description = description; 597 + if (content) { 598 + record.content = createMarkdownContent(content); 599 + record.textContent = content; 600 + } 601 + if (tags.length > 0) record.tags = tags; 589 602 590 - await session.agent!.com.atproto.repo.putRecord({ 591 - repo: session.did!, 592 - collection: DOCUMENT_COLLECTION, 593 - rkey, 594 - record, 595 - }); 603 + await session.agent!.com.atproto.repo.putRecord({ 604 + repo: session.did!, 605 + collection: DOCUMENT_COLLECTION, 606 + rkey, 607 + record, 608 + }); 596 609 597 - return c.redirect(`/documents/${rkey}`); 598 - } catch (error) { 599 - console.error("Error updating document:", error); 600 - return c.redirect(`/documents/${rkey}/edit?error=update_failed`); 601 - } 610 + return c.redirect(`/documents/${rkey}`); 611 + } catch (error) { 612 + console.error("Error updating document:", error); 613 + return c.redirect(`/documents/${rkey}/edit?error=update_failed`); 614 + } 602 615 }); 603 616 604 617 // Publish document 605 618 documentRoutes.post("/:rkey/publish", async (c) => { 606 - let session: Session; 607 - try { 608 - session = requireAuth(c); 609 - } catch { 610 - return c.redirect("/auth/login"); 611 - } 619 + let session: Session; 620 + try { 621 + session = requireAuth(c); 622 + } catch { 623 + return c.redirect("/auth/login"); 624 + } 612 625 613 - const rkey = c.req.param("rkey"); 626 + const rkey = c.req.param("rkey"); 614 627 615 - if (!isValidTID(rkey)) { 616 - return c.redirect("/documents"); 617 - } 628 + if (!isValidTID(rkey)) { 629 + return c.redirect("/documents"); 630 + } 618 631 619 - try { 620 - const existing = await session.agent!.com.atproto.repo.getRecord({ 621 - repo: session.did!, 622 - collection: DOCUMENT_COLLECTION, 623 - rkey, 624 - }); 632 + try { 633 + const existing = await session.agent!.com.atproto.repo.getRecord({ 634 + repo: session.did!, 635 + collection: DOCUMENT_COLLECTION, 636 + rkey, 637 + }); 625 638 626 - const doc = existing.data.value as any; 627 - const tags = (doc.tags || []).filter((t: string) => t !== "draft"); 639 + const doc = existing.data.value as any; 640 + const tags = (doc.tags || []).filter((t: string) => t !== "draft"); 628 641 629 - const record = { 630 - ...doc, 631 - tags: tags.length > 0 ? tags : undefined, 632 - publishedAt: doc.publishedAt || new Date().toISOString(), 633 - updatedAt: new Date().toISOString(), 634 - }; 642 + const record = { 643 + ...doc, 644 + tags: tags.length > 0 ? tags : undefined, 645 + publishedAt: doc.publishedAt || new Date().toISOString(), 646 + updatedAt: new Date().toISOString(), 647 + }; 635 648 636 - await session.agent!.com.atproto.repo.putRecord({ 637 - repo: session.did!, 638 - collection: DOCUMENT_COLLECTION, 639 - rkey, 640 - record, 641 - }); 649 + await session.agent!.com.atproto.repo.putRecord({ 650 + repo: session.did!, 651 + collection: DOCUMENT_COLLECTION, 652 + rkey, 653 + record, 654 + }); 642 655 643 - return c.redirect(`/documents/${rkey}`); 644 - } catch (error) { 645 - console.error("Error publishing document:", error); 646 - return c.redirect(`/documents/${rkey}?error=publish_failed`); 647 - } 656 + return c.redirect(`/documents/${rkey}`); 657 + } catch (error) { 658 + console.error("Error publishing document:", error); 659 + return c.redirect(`/documents/${rkey}?error=publish_failed`); 660 + } 648 661 }); 649 662 650 663 // Unpublish document (add draft tag) 651 664 documentRoutes.post("/:rkey/unpublish", async (c) => { 652 - let session: Session; 653 - try { 654 - session = requireAuth(c); 655 - } catch { 656 - return c.redirect("/auth/login"); 657 - } 665 + let session: Session; 666 + try { 667 + session = requireAuth(c); 668 + } catch { 669 + return c.redirect("/auth/login"); 670 + } 658 671 659 - const rkey = c.req.param("rkey"); 672 + const rkey = c.req.param("rkey"); 660 673 661 - if (!isValidTID(rkey)) { 662 - return c.redirect("/documents"); 663 - } 674 + if (!isValidTID(rkey)) { 675 + return c.redirect("/documents"); 676 + } 664 677 665 - try { 666 - const existing = await session.agent!.com.atproto.repo.getRecord({ 667 - repo: session.did!, 668 - collection: DOCUMENT_COLLECTION, 669 - rkey, 670 - }); 678 + try { 679 + const existing = await session.agent!.com.atproto.repo.getRecord({ 680 + repo: session.did!, 681 + collection: DOCUMENT_COLLECTION, 682 + rkey, 683 + }); 671 684 672 - const doc = existing.data.value as any; 673 - const tags = [...(doc.tags || []), "draft"]; 685 + const doc = existing.data.value as any; 686 + const tags = [...(doc.tags || []), "draft"]; 674 687 675 - const record = { 676 - ...doc, 677 - tags, 678 - updatedAt: new Date().toISOString(), 679 - }; 688 + const record = { 689 + ...doc, 690 + tags, 691 + updatedAt: new Date().toISOString(), 692 + }; 680 693 681 - await session.agent!.com.atproto.repo.putRecord({ 682 - repo: session.did!, 683 - collection: DOCUMENT_COLLECTION, 684 - rkey, 685 - record, 686 - }); 694 + await session.agent!.com.atproto.repo.putRecord({ 695 + repo: session.did!, 696 + collection: DOCUMENT_COLLECTION, 697 + rkey, 698 + record, 699 + }); 687 700 688 - return c.redirect(`/documents/${rkey}`); 689 - } catch (error) { 690 - console.error("Error unpublishing document:", error); 691 - return c.redirect(`/documents/${rkey}?error=unpublish_failed`); 692 - } 701 + return c.redirect(`/documents/${rkey}`); 702 + } catch (error) { 703 + console.error("Error unpublishing document:", error); 704 + return c.redirect(`/documents/${rkey}?error=unpublish_failed`); 705 + } 693 706 }); 694 707 695 708 // Delete document 696 709 documentRoutes.post("/:rkey/delete", async (c) => { 697 - let session: Session; 698 - try { 699 - session = requireAuth(c); 700 - } catch { 701 - return c.redirect("/auth/login"); 702 - } 710 + let session: Session; 711 + try { 712 + session = requireAuth(c); 713 + } catch { 714 + return c.redirect("/auth/login"); 715 + } 703 716 704 - const rkey = c.req.param("rkey"); 717 + const rkey = c.req.param("rkey"); 705 718 706 - if (!isValidTID(rkey)) { 707 - return c.redirect("/documents"); 708 - } 719 + if (!isValidTID(rkey)) { 720 + return c.redirect("/documents"); 721 + } 709 722 710 - try { 711 - await session.agent!.com.atproto.repo.deleteRecord({ 712 - repo: session.did!, 713 - collection: DOCUMENT_COLLECTION, 714 - rkey, 715 - }); 723 + try { 724 + await session.agent!.com.atproto.repo.deleteRecord({ 725 + repo: session.did!, 726 + collection: DOCUMENT_COLLECTION, 727 + rkey, 728 + }); 716 729 717 - return c.redirect("/documents"); 718 - } catch (error) { 719 - console.error("Error deleting document:", error); 720 - return c.redirect(`/documents/${rkey}?error=delete_failed`); 721 - } 730 + return c.redirect("/documents"); 731 + } catch (error) { 732 + console.error("Error deleting document:", error); 733 + return c.redirect(`/documents/${rkey}?error=delete_failed`); 734 + } 722 735 }); 723 736 724 737 // Generate a TID (timestamp-based ID) 725 738 function generateTID(): string { 726 - const now = Date.now() * 1000; 727 - const clockId = Math.floor(Math.random() * 1024); 728 - const tid = (BigInt(now) << 10n) | BigInt(clockId); 729 - return tid.toString(36).padStart(13, "0"); 739 + const now = Date.now() * 1000; 740 + const clockId = Math.floor(Math.random() * 1024); 741 + const tid = (BigInt(now) << 10n) | BigInt(clockId); 742 + return tid.toString(36).padStart(13, "0"); 730 743 }
+131 -129
packages/web/src/routes/publication.ts
··· 10 10 11 11 // View/manage publication 12 12 publicationRoutes.get("/", async (c) => { 13 - let session: Session; 14 - try { 15 - session = requireAuth(c); 16 - } catch { 17 - return c.redirect("/auth/login"); 18 - } 13 + let session: Session; 14 + try { 15 + session = requireAuth(c); 16 + } catch { 17 + return c.redirect("/auth/login"); 18 + } 19 19 20 - try { 21 - // Fetch existing publication 22 - const response = await session.agent!.com.atproto.repo.listRecords({ 23 - repo: session.did!, 24 - collection: PUBLICATION_COLLECTION, 25 - limit: 1, 26 - }); 20 + try { 21 + // Fetch existing publication 22 + const response = await session.agent!.com.atproto.repo.listRecords({ 23 + repo: session.did!, 24 + collection: PUBLICATION_COLLECTION, 25 + limit: 1, 26 + }); 27 27 28 - const publication = response.data.records[0]; 28 + const publication = response.data.records[0]; 29 29 30 - if (publication) { 31 - const pub = publication.value as any; 32 - const content = html` 30 + if (publication) { 31 + const pub = publication.value as any; 32 + const content = html` 33 33 <div class="publication"> 34 34 <h1>Your Publication</h1> 35 35 ··· 38 38 <p class="url"> 39 39 <a href="${pub.url}" target="_blank">${pub.url}</a> 40 40 </p> 41 - ${pub.description 42 - ? html`<p class="description">${pub.description}</p>` 43 - : ""} 41 + ${ 42 + pub.description 43 + ? html`<p class="description">${pub.description}</p>` 44 + : "" 45 + } 44 46 </div> 45 47 46 48 <div class="actions"> ··· 50 52 </div> 51 53 </div> 52 54 `; 53 - return c.html( 54 - layout(content, { title: "Publication - std.pub", session }), 55 - ); 56 - } 55 + return c.html( 56 + layout(content, { title: "Publication - std.pub", session }), 57 + ); 58 + } 57 59 58 - // No publication exists, show create form 59 - return c.redirect("/publication/new"); 60 - } catch (error) { 61 - console.error("Error fetching publication:", error); 62 - return c.redirect("/publication/new"); 63 - } 60 + // No publication exists, show create form 61 + return c.redirect("/publication/new"); 62 + } catch (error) { 63 + console.error("Error fetching publication:", error); 64 + return c.redirect("/publication/new"); 65 + } 64 66 }); 65 67 66 68 // New publication form 67 69 publicationRoutes.get("/new", async (c) => { 68 - let session: Session; 69 - try { 70 - session = requireAuth(c); 71 - } catch { 72 - return c.redirect("/auth/login"); 73 - } 70 + let session: Session; 71 + try { 72 + session = requireAuth(c); 73 + } catch { 74 + return c.redirect("/auth/login"); 75 + } 74 76 75 - const csrfToken = c.get("csrfToken") as string; 77 + const csrfToken = c.get("csrfToken") as string; 76 78 77 - const content = html` 79 + const content = html` 78 80 <div class="form-page"> 79 81 <h1>Create Publication</h1> 80 82 ··· 116 118 </div> 117 119 `; 118 120 119 - return c.html( 120 - layout(content, { title: "New Publication - std.pub", session }), 121 - ); 121 + return c.html( 122 + layout(content, { title: "New Publication - std.pub", session }), 123 + ); 122 124 }); 123 125 124 126 // Handle publication creation 125 127 publicationRoutes.post("/new", async (c) => { 126 - let session: Session; 127 - try { 128 - session = requireAuth(c); 129 - } catch { 130 - return c.redirect("/auth/login"); 131 - } 128 + let session: Session; 129 + try { 130 + session = requireAuth(c); 131 + } catch { 132 + return c.redirect("/auth/login"); 133 + } 132 134 133 - const body = await c.req.parseBody(); 134 - const name = body.name as string; 135 - const url = (body.url as string).replace(/\/$/, ""); // Remove trailing slash 136 - const description = (body.description as string) || undefined; 135 + const body = await c.req.parseBody(); 136 + const name = body.name as string; 137 + const url = (body.url as string).replace(/\/$/, ""); // Remove trailing slash 138 + const description = (body.description as string) || undefined; 137 139 138 - try { 139 - // Generate a TID for the record key 140 - const rkey = generateTID(); 140 + try { 141 + // Generate a TID for the record key 142 + const rkey = generateTID(); 141 143 142 - await session.agent!.com.atproto.repo.createRecord({ 143 - repo: session.did!, 144 - collection: PUBLICATION_COLLECTION, 145 - rkey, 146 - record: { 147 - $type: PUBLICATION_COLLECTION, 148 - name, 149 - url, 150 - ...(description && { description }), 151 - }, 152 - }); 144 + await session.agent!.com.atproto.repo.createRecord({ 145 + repo: session.did!, 146 + collection: PUBLICATION_COLLECTION, 147 + rkey, 148 + record: { 149 + $type: PUBLICATION_COLLECTION, 150 + name, 151 + url, 152 + ...(description && { description }), 153 + }, 154 + }); 153 155 154 - return c.redirect("/publication"); 155 - } catch (error) { 156 - console.error("Error creating publication:", error); 157 - return c.redirect("/publication/new?error=create_failed"); 158 - } 156 + return c.redirect("/publication"); 157 + } catch (error) { 158 + console.error("Error creating publication:", error); 159 + return c.redirect("/publication/new?error=create_failed"); 160 + } 159 161 }); 160 162 161 163 // Edit publication form 162 164 publicationRoutes.get("/edit", async (c) => { 163 - let session: Session; 164 - try { 165 - session = requireAuth(c); 166 - } catch { 167 - return c.redirect("/auth/login"); 168 - } 165 + let session: Session; 166 + try { 167 + session = requireAuth(c); 168 + } catch { 169 + return c.redirect("/auth/login"); 170 + } 169 171 170 - try { 171 - const response = await session.agent!.com.atproto.repo.listRecords({ 172 - repo: session.did!, 173 - collection: PUBLICATION_COLLECTION, 174 - limit: 1, 175 - }); 172 + try { 173 + const response = await session.agent!.com.atproto.repo.listRecords({ 174 + repo: session.did!, 175 + collection: PUBLICATION_COLLECTION, 176 + limit: 1, 177 + }); 176 178 177 - const publication = response.data.records[0]; 178 - if (!publication) { 179 - return c.redirect("/publication/new"); 180 - } 179 + const publication = response.data.records[0]; 180 + if (!publication) { 181 + return c.redirect("/publication/new"); 182 + } 181 183 182 - const pub = publication.value as any; 183 - const rkey = publication.uri.split("/").pop(); 184 + const pub = publication.value as any; 185 + const rkey = publication.uri.split("/").pop(); 184 186 185 - const csrfToken = c.get("csrfToken") as string; 187 + const csrfToken = c.get("csrfToken") as string; 186 188 187 - const content = html` 189 + const content = html` 188 190 <div class="form-page"> 189 191 <h1>Edit Publication</h1> 190 192 ··· 227 229 </div> 228 230 `; 229 231 230 - return c.html( 231 - layout(content, { title: "Edit Publication - std.pub", session }), 232 - ); 233 - } catch (error) { 234 - console.error("Error fetching publication:", error); 235 - return c.redirect("/publication"); 236 - } 232 + return c.html( 233 + layout(content, { title: "Edit Publication - std.pub", session }), 234 + ); 235 + } catch (error) { 236 + console.error("Error fetching publication:", error); 237 + return c.redirect("/publication"); 238 + } 237 239 }); 238 240 239 241 // Handle publication update 240 242 publicationRoutes.post("/edit", async (c) => { 241 - let session: Session; 242 - try { 243 - session = requireAuth(c); 244 - } catch { 245 - return c.redirect("/auth/login"); 246 - } 243 + let session: Session; 244 + try { 245 + session = requireAuth(c); 246 + } catch { 247 + return c.redirect("/auth/login"); 248 + } 247 249 248 - const body = await c.req.parseBody(); 249 - const rkey = body.rkey as string; 250 - const name = body.name as string; 251 - const url = (body.url as string).replace(/\/$/, ""); 252 - const description = (body.description as string) || undefined; 250 + const body = await c.req.parseBody(); 251 + const rkey = body.rkey as string; 252 + const name = body.name as string; 253 + const url = (body.url as string).replace(/\/$/, ""); 254 + const description = (body.description as string) || undefined; 253 255 254 - try { 255 - await session.agent!.com.atproto.repo.putRecord({ 256 - repo: session.did!, 257 - collection: PUBLICATION_COLLECTION, 258 - rkey, 259 - record: { 260 - $type: PUBLICATION_COLLECTION, 261 - name, 262 - url, 263 - ...(description && { description }), 264 - }, 265 - }); 256 + try { 257 + await session.agent!.com.atproto.repo.putRecord({ 258 + repo: session.did!, 259 + collection: PUBLICATION_COLLECTION, 260 + rkey, 261 + record: { 262 + $type: PUBLICATION_COLLECTION, 263 + name, 264 + url, 265 + ...(description && { description }), 266 + }, 267 + }); 266 268 267 - return c.redirect("/publication"); 268 - } catch (error) { 269 - console.error("Error updating publication:", error); 270 - return c.redirect("/publication/edit?error=update_failed"); 271 - } 269 + return c.redirect("/publication"); 270 + } catch (error) { 271 + console.error("Error updating publication:", error); 272 + return c.redirect("/publication/edit?error=update_failed"); 273 + } 272 274 }); 273 275 274 276 // Generate a TID (timestamp-based ID) 275 277 function generateTID(): string { 276 - const now = Date.now() * 1000; // microseconds 277 - const clockId = Math.floor(Math.random() * 1024); 278 - const tid = (BigInt(now) << 10n) | BigInt(clockId); 279 - return tid.toString(36).padStart(13, "0"); 278 + const now = Date.now() * 1000; // microseconds 279 + const clockId = Math.floor(Math.random() * 1024); 280 + const tid = (BigInt(now) << 10n) | BigInt(clockId); 281 + return tid.toString(36).padStart(13, "0"); 280 282 }
+56 -54
packages/web/src/server.ts
··· 1 - import { Hono } from 'hono'; 2 - import { serveStatic } from 'hono/bun'; 3 - import { getCookie, setCookie, deleteCookie } from 'hono/cookie'; 4 - import { authRoutes } from './routes/auth'; 5 - import { publicationRoutes } from './routes/publication'; 6 - import { documentRoutes } from './routes/documents'; 7 - import { layout } from './views/layouts/main'; 8 - import { homePage } from './views/home'; 9 - import { getSession } from './lib/session'; 10 - import { getClientMetadata, getJwks } from './lib/oauth'; 11 - import { csrfProtection, getCSRFToken } from './lib/csrf'; 1 + import { Hono } from "hono"; 2 + import { serveStatic } from "hono/bun"; 3 + import { getCookie, setCookie, deleteCookie } from "hono/cookie"; 4 + import { authRoutes } from "./routes/auth"; 5 + import { publicationRoutes } from "./routes/publication"; 6 + import { documentRoutes } from "./routes/documents"; 7 + import { layout } from "./views/layouts/main"; 8 + import { homePage } from "./views/home"; 9 + import { getSession } from "./lib/session"; 10 + import { getClientMetadata, getJwks } from "./lib/oauth"; 11 + import { csrfProtection, getCSRFToken } from "./lib/csrf"; 12 12 13 13 export const app = new Hono(); 14 14 15 15 // Static files 16 - app.use('/public/*', serveStatic({ root: './' })); 16 + app.use("/public/*", serveStatic({ root: "./" })); 17 17 18 18 // OAuth metadata endpoints at root level 19 19 // These MUST be publicly accessible (no authentication) 20 - app.get('/client-metadata.json', async (c) => { 21 - try { 22 - const metadata = await getClientMetadata(); 23 - // Set appropriate cache headers 24 - c.header('Cache-Control', 'public, max-age=600'); // Cache for 10 minutes 25 - c.header('Access-Control-Allow-Origin', '*'); 26 - return c.json(metadata); 27 - } catch (error) { 28 - console.error('Error getting client metadata:', error); 29 - return c.json({ error: 'Failed to get client metadata' }, 500); 30 - } 20 + app.get("/client-metadata.json", async (c) => { 21 + try { 22 + const metadata = await getClientMetadata(); 23 + // Set appropriate cache headers 24 + c.header("Cache-Control", "public, max-age=600"); // Cache for 10 minutes 25 + c.header("Access-Control-Allow-Origin", "*"); 26 + return c.json(metadata); 27 + } catch (error) { 28 + console.error("Error getting client metadata:", error); 29 + return c.json({ error: "Failed to get client metadata" }, 500); 30 + } 31 31 }); 32 32 33 - app.get('/jwks.json', async (c) => { 34 - try { 35 - const jwks = await getJwks(); 36 - // Set appropriate cache headers 37 - c.header('Cache-Control', 'public, max-age=600'); // Cache for 10 minutes 38 - c.header('Access-Control-Allow-Origin', '*'); 39 - return c.json(jwks); 40 - } catch (error) { 41 - console.error('Error getting JWKS:', error); 42 - return c.json({ error: 'Failed to get JWKS' }, 500); 43 - } 33 + app.get("/jwks.json", async (c) => { 34 + try { 35 + const jwks = await getJwks(); 36 + // Set appropriate cache headers 37 + c.header("Cache-Control", "public, max-age=600"); // Cache for 10 minutes 38 + c.header("Access-Control-Allow-Origin", "*"); 39 + return c.json(jwks); 40 + } catch (error) { 41 + console.error("Error getting JWKS:", error); 42 + return c.json({ error: "Failed to get JWKS" }, 500); 43 + } 44 44 }); 45 45 46 46 // Session middleware - adds session and CSRF token to context 47 - app.use('*', async (c, next) => { 48 - const session = await getSession(c); 49 - c.set('session', session); 50 - // Generate CSRF token for all requests (sets cookie if not present) 51 - const csrfToken = getCSRFToken(c); 52 - c.set('csrfToken', csrfToken); 53 - await next(); 47 + app.use("*", async (c, next) => { 48 + const session = await getSession(c); 49 + c.set("session", session); 50 + // Generate CSRF token for all requests (sets cookie if not present) 51 + const csrfToken = getCSRFToken(c); 52 + c.set("csrfToken", csrfToken); 53 + await next(); 54 54 }); 55 55 56 56 // CSRF protection for state-changing requests 57 57 // Applied after session middleware but before routes 58 - app.use('/auth/*', csrfProtection); 59 - app.use('/publication/*', csrfProtection); 60 - app.use('/documents/*', csrfProtection); 58 + app.use("/auth/*", csrfProtection); 59 + app.use("/publication/*", csrfProtection); 60 + app.use("/documents/*", csrfProtection); 61 61 62 62 // Home page 63 - app.get('/', async (c) => { 64 - const session = c.get('session'); 65 - return c.html(layout(homePage(session), { session })); 63 + app.get("/", async (c) => { 64 + const session = c.get("session"); 65 + return c.html(layout(homePage(session), { session })); 66 66 }); 67 67 68 68 // Mount routes 69 - app.route('/auth', authRoutes); 70 - app.route('/publication', publicationRoutes); 71 - app.route('/documents', documentRoutes); 69 + app.route("/auth", authRoutes); 70 + app.route("/publication", publicationRoutes); 71 + app.route("/documents", documentRoutes); 72 72 73 - const port = parseInt(process.env.PORT || '8000'); 73 + const port = parseInt(process.env.PORT || "8000"); 74 74 console.log(`Starting server on http://localhost:${port}`); 75 - console.log(`Public URL: ${process.env.PUBLIC_URL || 'http://localhost:' + port}`); 75 + console.log( 76 + `Public URL: ${process.env.PUBLIC_URL || "http://localhost:" + port}`, 77 + ); 76 78 77 79 export default { 78 - port, 79 - fetch: app.fetch, 80 + port, 81 + fetch: app.fetch, 80 82 };
+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>std.pub</h1> 23 23 <p>
+10 -8
packages/web/src/views/layouts/main.ts
··· 2 2 import type { Session } from "../../lib/session"; 3 3 4 4 interface LayoutOptions { 5 - title?: string; 6 - session?: Session; 7 - csrfToken?: string; 5 + title?: string; 6 + session?: Session; 7 + csrfToken?: string; 8 8 } 9 9 10 10 export function layout(content: string, options: LayoutOptions = {}) { 11 - const { title = "std.pub", session } = options; 11 + const { title = "std.pub", session } = options; 12 12 13 - return html` 13 + return html` 14 14 <!DOCTYPE html> 15 15 <html lang="en"> 16 16 <head> ··· 24 24 <nav class="nav"> 25 25 <a href="/" class="logo">std.pub</a> 26 26 <div class="nav-links"> 27 - ${session?.did 28 - ? html` 27 + ${ 28 + session?.did 29 + ? html` 29 30 <a href="/publication">Publication</a> 30 31 <a href="/documents">Documents</a> 31 32 <span class="handle">@${session.handle}</span> 32 33 <a href="/auth/logout">Logout</a> 33 34 ` 34 - : html` <a href="/auth/login">Login with Bluesky</a> `} 35 + : html` <a href="/auth/login">Login with Bluesky</a> ` 36 + } 35 37 </div> 36 38 </nav> 37 39 </header>