snow-editor

small markdown and org-mode editor
Log | Files | Refs | README

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;