documents.js (10787B)
1 import { Router } from 'express'; 2 import rateLimit from 'express-rate-limit'; 3 import { checkDbHealth, getDb, purgeExpiredLocks } from '../db.js'; 4 import { 5 APP_VERSION, 6 DEFAULT_DOCUMENT_TITLE, 7 MSG, 8 } from '../messages.js'; 9 import { requireAllowedOrigin } from '../originGuard.js'; 10 import { saveDocumentVersion } from '../versionUtils.js'; 11 import { 12 assertContentSize, 13 assertMode, 14 isExpired, 15 lockExpiresAtFromNow, 16 newId, 17 parseExpiresIn, 18 secureToken, 19 sendError, 20 } from '../utils.js'; 21 22 const router = Router(); 23 24 const createDocLimiter = rateLimit({ 25 windowMs: 60 * 1000, 26 max: 10, 27 standardHeaders: true, 28 legacyHeaders: false, 29 message: { 30 error: 'RATE_LIMIT', 31 message: MSG.RATE_LIMIT, 32 }, 33 }); 34 35 function getDocumentByViewToken(token) { 36 return getDb() 37 .prepare('SELECT * FROM documents WHERE view_token = ?') 38 .get(token); 39 } 40 41 function getDocumentByEditToken(token) { 42 return getDb() 43 .prepare('SELECT * FROM documents WHERE edit_token = ?') 44 .get(token); 45 } 46 47 function documentToPublic(doc) { 48 return { 49 id: doc.id, 50 title: doc.title, 51 mode: doc.mode, 52 content: doc.content, 53 expiresAt: doc.expires_at, 54 createdAt: doc.created_at, 55 updatedAt: doc.updated_at, 56 }; 57 } 58 59 function checkDocumentAccess(doc, res) { 60 if (!doc) { 61 sendError(res, 404, 'NOT_FOUND', MSG.NOT_FOUND); 62 return false; 63 } 64 if (isExpired(doc.expires_at)) { 65 sendError(res, 410, 'EXPIRED', MSG.EXPIRED); 66 return false; 67 } 68 return true; 69 } 70 71 function getActiveLock(documentId) { 72 purgeExpiredLocks(getDb()); 73 const now = new Date().toISOString(); 74 return getDb() 75 .prepare( 76 'SELECT * FROM edit_locks WHERE document_id = ? AND expires_at > ? ORDER BY created_at DESC LIMIT 1', 77 ) 78 .get(documentId, now); 79 } 80 81 function validateLock(doc, clientId, lockToken) { 82 purgeExpiredLocks(getDb()); 83 const lock = getDb() 84 .prepare( 85 'SELECT * FROM edit_locks WHERE document_id = ? AND lock_token = ? AND client_id = ?', 86 ) 87 .get(doc.id, lockToken, clientId); 88 89 if (!lock || isExpired(lock.expires_at)) { 90 return null; 91 } 92 return lock; 93 } 94 95 router.get('/health', (_req, res) => { 96 const dbOk = checkDbHealth(getDb()); 97 const payload = { 98 ok: dbOk, 99 db: dbOk ? 'ok' : 'error', 100 uptime: Math.floor(process.uptime()), 101 version: APP_VERSION, 102 }; 103 104 if (!dbOk) { 105 console.error('[health] database check failed'); 106 return res.status(503).json(payload); 107 } 108 109 res.json(payload); 110 }); 111 112 router.post('/documents', createDocLimiter, requireAllowedOrigin, (req, res) => { 113 const { title, mode, content, expiresIn } = req.body ?? {}; 114 115 if (!assertMode(mode)) { 116 return sendError(res, 400, 'INVALID_MODE', MSG.INVALID_MODE); 117 } 118 119 if (typeof content !== 'string') { 120 return sendError(res, 400, 'INVALID_CONTENT', MSG.INVALID_CONTENT); 121 } 122 123 if (!assertContentSize(content)) { 124 return sendError(res, 413, 'CONTENT_TOO_LARGE', MSG.CONTENT_TOO_LARGE); 125 } 126 127 const expiresAt = parseExpiresIn(expiresIn); 128 if (expiresAt === undefined) { 129 return sendError(res, 400, 'INVALID_EXPIRES_IN', MSG.INVALID_EXPIRES_IN); 130 } 131 132 const id = newId(); 133 const viewToken = secureToken(); 134 const editToken = secureToken(); 135 const docTitle = 136 typeof title === 'string' && title.trim() ? title.trim() : DEFAULT_DOCUMENT_TITLE; 137 const now = new Date().toISOString(); 138 139 getDb() 140 .prepare( 141 `INSERT INTO documents (id, title, mode, content, view_token, edit_token, expires_at, created_at, updated_at) 142 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 143 ) 144 .run(id, docTitle, mode, content, viewToken, editToken, expiresAt, now, now); 145 146 res.status(201).json({ 147 id, 148 title: docTitle, 149 mode, 150 viewToken, 151 editToken, 152 viewUrl: `/v/${viewToken}`, 153 editUrl: `/e/${editToken}`, 154 expiresAt, 155 }); 156 }); 157 158 router.get('/documents/view/:token', (req, res) => { 159 const doc = getDocumentByViewToken(req.params.token); 160 if (!checkDocumentAccess(doc, res)) return; 161 res.json(documentToPublic(doc)); 162 }); 163 164 router.get('/documents/edit/:token', (req, res) => { 165 const doc = getDocumentByEditToken(req.params.token); 166 if (!checkDocumentAccess(doc, res)) return; 167 res.json(documentToPublic(doc)); 168 }); 169 170 router.get('/documents/edit/:token/versions', (req, res) => { 171 const { clientId, lockToken } = req.query; 172 173 if (!clientId || !lockToken) { 174 return sendError(res, 403, 'LOCK_REQUIRED', MSG.LOCK_REQUIRED); 175 } 176 177 const doc = getDocumentByEditToken(req.params.token); 178 if (!checkDocumentAccess(doc, res)) return; 179 180 const lock = validateLock(doc, String(clientId), String(lockToken)); 181 if (!lock) { 182 return sendError(res, 403, 'LOCK_REQUIRED', MSG.LOCK_REQUIRED); 183 } 184 185 const versions = getDb() 186 .prepare( 187 `SELECT id, created_at FROM document_versions 188 WHERE document_id = ? 189 ORDER BY created_at DESC 190 LIMIT 20`, 191 ) 192 .all(doc.id) 193 .map((row) => ({ 194 id: row.id, 195 createdAt: row.created_at, 196 })); 197 198 res.json({ versions }); 199 }); 200 201 router.post('/documents/edit/:token/versions/:versionId/restore', (req, res) => { 202 const { clientId, lockToken } = req.body ?? {}; 203 204 if (!clientId || !lockToken) { 205 return sendError(res, 403, 'LOCK_REQUIRED', MSG.LOCK_REQUIRED); 206 } 207 208 const doc = getDocumentByEditToken(req.params.token); 209 if (!checkDocumentAccess(doc, res)) return; 210 211 const lock = validateLock(doc, clientId, lockToken); 212 if (!lock) { 213 return sendError(res, 403, 'LOCK_REQUIRED', MSG.LOCK_REQUIRED); 214 } 215 216 const version = getDb() 217 .prepare( 218 'SELECT * FROM document_versions WHERE id = ? AND document_id = ?', 219 ) 220 .get(req.params.versionId, doc.id); 221 222 if (!version) { 223 return sendError(res, 404, 'VERSION_NOT_FOUND', MSG.VERSION_NOT_FOUND); 224 } 225 226 const now = new Date().toISOString(); 227 const db = getDb(); 228 229 saveDocumentVersion(db, doc, now); 230 231 db.prepare( 232 `UPDATE documents SET title = ?, mode = ?, content = ?, updated_at = ? WHERE id = ?`, 233 ).run(version.title, version.mode, version.content, now, doc.id); 234 235 const lockExpires = lockExpiresAtFromNow(); 236 db.prepare('UPDATE edit_locks SET expires_at = ?, updated_at = ? WHERE id = ?').run( 237 lockExpires, 238 now, 239 lock.id, 240 ); 241 242 res.json({ 243 success: true, 244 title: version.title, 245 mode: version.mode, 246 content: version.content, 247 updated_at: now, 248 }); 249 }); 250 251 router.post('/documents/edit/:token/lock', (req, res) => { 252 const { clientId } = req.body ?? {}; 253 if (!clientId || typeof clientId !== 'string') { 254 return sendError(res, 400, 'INVALID_CLIENT', MSG.INVALID_CLIENT); 255 } 256 257 const doc = getDocumentByEditToken(req.params.token); 258 if (!checkDocumentAccess(doc, res)) return; 259 260 purgeExpiredLocks(getDb()); 261 const activeLock = getActiveLock(doc.id); 262 263 if (activeLock) { 264 if (activeLock.client_id === clientId) { 265 const expiresAt = lockExpiresAtFromNow(); 266 const now = new Date().toISOString(); 267 getDb() 268 .prepare( 269 'UPDATE edit_locks SET expires_at = ?, updated_at = ? WHERE id = ?', 270 ) 271 .run(expiresAt, now, activeLock.id); 272 273 return res.json({ 274 locked: true, 275 lockToken: activeLock.lock_token, 276 expiresAt, 277 }); 278 } 279 280 return res.status(423).json({ 281 locked: false, 282 error: 'DOCUMENT_LOCKED', 283 message: MSG.DOCUMENT_LOCKED, 284 lockExpiresAt: activeLock.expires_at, 285 }); 286 } 287 288 const lockId = newId(); 289 const lockToken = secureToken(); 290 const expiresAt = lockExpiresAtFromNow(); 291 const now = new Date().toISOString(); 292 293 getDb() 294 .prepare( 295 `INSERT INTO edit_locks (id, document_id, lock_token, client_id, expires_at, created_at, updated_at) 296 VALUES (?, ?, ?, ?, ?, ?, ?)`, 297 ) 298 .run(lockId, doc.id, lockToken, clientId, expiresAt, now, now); 299 300 res.json({ locked: true, lockToken, expiresAt }); 301 }); 302 303 router.post('/documents/edit/:token/lock/refresh', (req, res) => { 304 const { clientId, lockToken } = req.body ?? {}; 305 if (!clientId || !lockToken) { 306 return sendError(res, 400, 'INVALID_REQUEST', MSG.INVALID_REQUEST); 307 } 308 309 const doc = getDocumentByEditToken(req.params.token); 310 if (!checkDocumentAccess(doc, res)) return; 311 312 purgeExpiredLocks(getDb()); 313 const lock = getDb() 314 .prepare( 315 'SELECT * FROM edit_locks WHERE document_id = ? AND lock_token = ? AND client_id = ?', 316 ) 317 .get(doc.id, lockToken, clientId); 318 319 if (!lock || isExpired(lock.expires_at)) { 320 return sendError(res, 403, 'LOCK_INVALID', MSG.LOCK_INVALID); 321 } 322 323 const expiresAt = lockExpiresAtFromNow(); 324 const now = new Date().toISOString(); 325 getDb() 326 .prepare('UPDATE edit_locks SET expires_at = ?, updated_at = ? WHERE id = ?') 327 .run(expiresAt, now, lock.id); 328 329 res.json({ locked: true, lockToken, expiresAt }); 330 }); 331 332 router.delete('/documents/edit/:token/lock', (req, res) => { 333 const { clientId, lockToken } = req.body ?? {}; 334 if (!clientId || !lockToken) { 335 return sendError(res, 400, 'INVALID_REQUEST', MSG.INVALID_REQUEST); 336 } 337 338 const doc = getDocumentByEditToken(req.params.token); 339 if (!doc) { 340 return sendError(res, 404, 'NOT_FOUND', MSG.NOT_FOUND); 341 } 342 343 const result = getDb() 344 .prepare( 345 'DELETE FROM edit_locks WHERE document_id = ? AND lock_token = ? AND client_id = ?', 346 ) 347 .run(doc.id, lockToken, clientId); 348 349 if (result.changes === 0) { 350 return sendError(res, 403, 'LOCK_INVALID', MSG.LOCK_INVALID); 351 } 352 353 res.json({ success: true }); 354 }); 355 356 router.put('/documents/edit/:token', (req, res) => { 357 const { clientId, lockToken, title, mode, content } = req.body ?? {}; 358 359 if (!clientId || !lockToken) { 360 return sendError(res, 403, 'LOCK_REQUIRED', MSG.LOCK_REQUIRED); 361 } 362 363 const doc = getDocumentByEditToken(req.params.token); 364 if (!checkDocumentAccess(doc, res)) return; 365 366 const lock = validateLock(doc, clientId, lockToken); 367 if (!lock) { 368 return sendError(res, 403, 'LOCK_REQUIRED', MSG.LOCK_REQUIRED); 369 } 370 371 if (!assertMode(mode)) { 372 return sendError(res, 400, 'INVALID_MODE', MSG.INVALID_MODE); 373 } 374 375 if (typeof content !== 'string') { 376 return sendError(res, 400, 'INVALID_CONTENT', MSG.INVALID_CONTENT); 377 } 378 379 if (!assertContentSize(content)) { 380 return sendError(res, 413, 'CONTENT_TOO_LARGE', MSG.CONTENT_TOO_LARGE); 381 } 382 383 const docTitle = 384 typeof title === 'string' && title.trim() ? title.trim() : doc.title; 385 const now = new Date().toISOString(); 386 const db = getDb(); 387 388 saveDocumentVersion(db, doc, now); 389 390 db.prepare( 391 `UPDATE documents SET title = ?, mode = ?, content = ?, updated_at = ? WHERE id = ?`, 392 ).run(docTitle, mode, content, now, doc.id); 393 394 const expiresAt = lockExpiresAtFromNow(); 395 db.prepare('UPDATE edit_locks SET expires_at = ?, updated_at = ? WHERE id = ?').run( 396 expiresAt, 397 now, 398 lock.id, 399 ); 400 401 res.json({ success: true, updated_at: now }); 402 }); 403 404 export default router;