const express = require('express'); const cookieParser = require('cookie-parser'); const crypto = require('crypto'); const path = require('path'); const app = express(); app.use(express.json({ limit: '5mb' })); app.use(cookieParser()); const PORT = process.env.PORT || 3000; const PASSWORD = process.env.TDB_PASSWORD || 'rdb2026'; const SESSION_SECRET = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex'); const NOCODB_URL = process.env.NOCODB_URL || 'https://nocodb.hub.delvarre.net'; const NOCODB_TOKEN = process.env.NOCODB_TOKEN || ''; const NOCODB_BASE_ID = process.env.NOCODB_BASE_ID || ''; const NOCODB_TABLE_ID = process.env.NOCODB_TABLE_ID || ''; const NOCODB_VISION_TABLE_ID = process.env.NOCODB_VISION_TABLE_ID || ''; const NOCODB_CONTENU_TABLE_ID = process.env.NOCODB_CONTENU_TABLE_ID || ''; const NOCODB_PERSONNAGES_TABLE_ID = process.env.NOCODB_PERSONNAGES_TABLE_ID || ''; const NOCODB_RESSOURCES_TABLE_ID = process.env.NOCODB_RESSOURCES_TABLE_ID || ''; const NOCODB_VERSIONS_TABLE_ID = process.env.NOCODB_VERSIONS_TABLE_ID || ''; // ── Version history helper ── async function saveVersion(type, recordId, snapshot, label) { if (!NOCODB_VERSIONS_TABLE_ID) return; try { const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_VERSIONS_TABLE_ID}`; await fetch(url, { method: 'POST', headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ Type: type, RecordId: recordId, Snapshot: JSON.stringify(snapshot), Label: label || '', }), }); } catch (e) { console.error('saveVersion error:', e.message); } } // Auth function makeToken(ts) { return crypto.createHmac('sha256', SESSION_SECRET).update(`tdb-${ts}`).digest('hex'); } function isAuth(req) { const ts = req.cookies?.tdb_ts; const tok = req.cookies?.tdb_tok; if (!ts || !tok) return false; if (Date.now() - Number(ts) > 7 * 24 * 3600 * 1000) return false; return tok === makeToken(ts); } // Login app.post('/api/login', (req, res) => { if (req.body?.password !== PASSWORD) return res.status(401).json({ error: 'Mot de passe incorrect' }); const ts = String(Date.now()); const tok = makeToken(ts); const opts = { httpOnly: true, sameSite: 'lax', maxAge: 7 * 24 * 3600 * 1000, secure: false }; res.cookie('tdb_ts', ts, opts); res.cookie('tdb_tok', tok, opts); res.json({ ok: true }); }); // Auth middleware for API function requireAuth(req, res, next) { if (!isAuth(req)) return res.status(401).json({ error: 'Non authentifié' }); next(); } // Proxy NocoDB — list records app.get('/api/records', requireAuth, async (req, res) => { try { const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}?limit=200&sort=-Ordre`; const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } }); const data = await r.json(); res.json(data); } catch (e) { res.status(500).json({ error: e.message }); } }); // Proxy NocoDB — create record app.post('/api/records', requireAuth, async (req, res) => { try { const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}`; const r = await fetch(url, { method: 'POST', headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify(req.body), }); const data = await r.json(); res.status(r.status).json(data); } catch (e) { res.status(500).json({ error: e.message }); } }); // Proxy NocoDB — update record (with version snapshot) app.patch('/api/records/:id', requireAuth, async (req, res) => { try { const recordId = Number(req.params.id); // Snapshot current state before update const getUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${recordId}`; const getR = await fetch(getUrl, { headers: { 'xc-token': NOCODB_TOKEN } }); if (getR.ok) { const current = await getR.json(); await saveVersion('frise', recordId, current, current.Titre || ''); } // Proceed with update const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${recordId}`; const r = await fetch(url, { method: 'PATCH', headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify(req.body), }); const data = await r.json(); res.status(r.status).json(data); } catch (e) { res.status(500).json({ error: e.message }); } }); // Proxy NocoDB — delete record (archive before deletion) app.delete('/api/records/:id', requireAuth, async (req, res) => { try { const recordId = Number(req.params.id); // Archive before delete const getUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${recordId}`; const getR = await fetch(getUrl, { headers: { 'xc-token': NOCODB_TOKEN } }); if (getR.ok) { const current = await getR.json(); await saveVersion('frise', recordId, current, `[supprimé] ${current.Titre || ''}`); } const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${recordId}`; const r = await fetch(url, { method: 'DELETE', headers: { 'xc-token': NOCODB_TOKEN }, }); res.status(r.status).json({ ok: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Proxy NocoDB — get column options (for dynamic selects) app.get('/api/columns', requireAuth, async (req, res) => { try { const url = `${NOCODB_URL}/api/v2/meta/tables/${NOCODB_TABLE_ID}`; const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } }); const table = await r.json(); const selectCols = {}; for (const col of table.columns || []) { if (col.uidt === 'SingleSelect' || col.uidt === 'MultiSelect') { selectCols[col.title] = { type: col.uidt, options: (col.colOptions?.options || []).map(o => o.title), }; } } res.json(selectCols); } catch (e) { res.status(500).json({ error: e.message }); } }); // Add a new Personnage option app.post('/api/columns/personnage', requireAuth, async (req, res) => { try { const { name } = req.body; if (!name) return res.status(400).json({ error: 'Nom requis' }); // Get current column meta const metaUrl = `${NOCODB_URL}/api/v2/meta/tables/${NOCODB_TABLE_ID}`; const metaRes = await fetch(metaUrl, { headers: { 'xc-token': NOCODB_TOKEN } }); const table = await metaRes.json(); const col = table.columns.find(c => c.title === 'Personnage'); if (!col) return res.status(404).json({ error: 'Colonne Personnage introuvable' }); const options = col.colOptions?.options || []; if (options.some(o => o.title === name)) return res.status(409).json({ error: 'Existe déjà' }); options.push({ title: name }); const patchUrl = `${NOCODB_URL}/api/v2/meta/columns/${col.id}`; const patchRes = await fetch(patchUrl, { method: 'PATCH', headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ colOptions: { options } }), }); if (!patchRes.ok) return res.status(500).json({ error: 'Erreur NocoDB' }); res.json({ ok: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Bulk update order (for drag-and-drop) app.post('/api/reorder', requireAuth, async (req, res) => { try { const updates = req.body; // [{id, Acte, Ordre}, ...] for (const u of updates) { const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${u.id}`; await fetch(url, { method: 'PATCH', headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ Acte: u.Acte, Ordre: u.Ordre }), }); } res.json({ ok: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ── Vision API ── // List all vision sections app.get('/api/vision', requireAuth, async (req, res) => { try { const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_VISION_TABLE_ID}?limit=50&sort=Ordre`; const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } }); const data = await r.json(); res.json(data); } catch (e) { res.status(500).json({ error: e.message }); } }); // Update a vision section app.patch('/api/vision/:id', requireAuth, async (req, res) => { try { const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_VISION_TABLE_ID}/${req.params.id}`; const r = await fetch(url, { method: 'PATCH', headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify(req.body), }); const data = await r.json(); res.status(r.status).json(data); } catch (e) { res.status(500).json({ error: e.message }); } }); // ── Contenu API (scene text for Rédaction view) ── // List all word counts (for binder display) app.get('/api/contenu', requireAuth, async (req, res) => { try { const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}?limit=200&fields=SceneId,Mots`; const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } }); const data = await r.json(); res.json(data); } catch (e) { res.status(500).json({ error: e.message }); } }); // Get content for a scene app.get('/api/contenu/:sceneId', requireAuth, async (req, res) => { try { const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}?where=(SceneId,eq,${req.params.sceneId})&limit=1`; const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } }); const data = await r.json(); const row = data.list?.[0] || null; res.json(row); } catch (e) { res.status(500).json({ error: e.message }); } }); // Save content for a scene (create or update) app.put('/api/contenu/:sceneId', requireAuth, async (req, res) => { try { const sceneId = Number(req.params.sceneId); // Check if row exists const findUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}?where=(SceneId,eq,${sceneId})&limit=1`; const findR = await fetch(findUrl, { headers: { 'xc-token': NOCODB_TOKEN } }); const findData = await findR.json(); const existing = findData.list?.[0]; const payload = { SceneId: sceneId, Texte: req.body.Texte, Mots: req.body.Mots || 0 }; if (existing) { // Snapshot before update await saveVersion('redaction', sceneId, { Texte: existing.Texte, Mots: existing.Mots, SceneId: sceneId }, `Scène #${sceneId}`); // Update const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}/${existing.Id}`; const r = await fetch(url, { method: 'PATCH', headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await r.json(); res.json(data); } else { // Create const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}`; const r = await fetch(url, { method: 'POST', headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await r.json(); res.status(r.status).json(data); } } catch (e) { res.status(500).json({ error: e.message }); } }); // ── Versions API ── // List versions for a record app.get('/api/versions/:type/:recordId', requireAuth, async (req, res) => { if (!NOCODB_VERSIONS_TABLE_ID) return res.json({ list: [] }); try { const { type, recordId } = req.params; const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_VERSIONS_TABLE_ID}?where=(Type,eq,${type})~and(RecordId,eq,${recordId})&sort=-CreatedAt&limit=50`; const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } }); const data = await r.json(); res.json(data); } catch (e) { res.status(500).json({ error: e.message }); } }); // Restore a frise version app.post('/api/versions/:id/restore', requireAuth, async (req, res) => { if (!NOCODB_VERSIONS_TABLE_ID) return res.status(400).json({ error: 'Versions non configurées' }); try { // Get the version const vUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_VERSIONS_TABLE_ID}/${req.params.id}`; const vR = await fetch(vUrl, { headers: { 'xc-token': NOCODB_TOKEN } }); if (!vR.ok) return res.status(404).json({ error: 'Version introuvable' }); const version = await vR.json(); const snapshot = JSON.parse(version.Snapshot); if (version.Type === 'frise') { // Snapshot current before restoring const getUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${version.RecordId}`; const getR = await fetch(getUrl, { headers: { 'xc-token': NOCODB_TOKEN } }); if (getR.ok) { const current = await getR.json(); await saveVersion('frise', version.RecordId, current, `[avant restauration] ${current.Titre || ''}`); } // Restore — only user-editable fields const { Titre, Resume, Personnage, Tags, Notes, Acte, Ordre } = snapshot; const patchUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${version.RecordId}`; const r = await fetch(patchUrl, { method: 'PATCH', headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ Titre, Resume, Personnage, Tags, Notes, Acte, Ordre }), }); res.json(await r.json()); } else if (version.Type === 'redaction') { // Snapshot current text before restoring const findUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}?where=(SceneId,eq,${version.RecordId})&limit=1`; const findR = await fetch(findUrl, { headers: { 'xc-token': NOCODB_TOKEN } }); const findData = await findR.json(); const existing = findData.list?.[0]; if (existing) { await saveVersion('redaction', version.RecordId, { Texte: existing.Texte, Mots: existing.Mots, SceneId: version.RecordId }, `[avant restauration] Scène #${version.RecordId}`); const patchUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}/${existing.Id}`; const r = await fetch(patchUrl, { method: 'PATCH', headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ Texte: snapshot.Texte, Mots: snapshot.Mots || 0 }), }); res.json(await r.json()); } else { // Re-create content row const createUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}`; const r = await fetch(createUrl, { method: 'POST', headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ SceneId: version.RecordId, Texte: snapshot.Texte, Mots: snapshot.Mots || 0 }), }); res.json(await r.json()); } } else { res.status(400).json({ error: 'Type inconnu' }); } } catch (e) { res.status(500).json({ error: e.message }); } }); // List archived (deleted) frise cards app.get('/api/archives', requireAuth, async (req, res) => { if (!NOCODB_VERSIONS_TABLE_ID) return res.json({ list: [] }); try { const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_VERSIONS_TABLE_ID}?where=(Type,eq,frise)~and(Label,like,%25supprimé%25)&sort=-CreatedAt&limit=100`; const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } }); const data = await r.json(); res.json(data); } catch (e) { res.status(500).json({ error: e.message }); } }); // ── Personnages API ── function personnagesProxy(tableIdVar) { return NOCODB_PERSONNAGES_TABLE_ID; } app.get('/api/personnages', requireAuth, async (req, res) => { if (!NOCODB_PERSONNAGES_TABLE_ID) return res.json({ list: [] }); try { const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_PERSONNAGES_TABLE_ID}?limit=100&sort=Nom`; const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } }); res.json(await r.json()); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/api/personnages', requireAuth, async (req, res) => { if (!NOCODB_PERSONNAGES_TABLE_ID) return res.status(400).json({ error: 'Table non configurée' }); try { const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_PERSONNAGES_TABLE_ID}`; const r = await fetch(url, { method: 'POST', headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify(req.body), }); res.status(r.status).json(await r.json()); } catch (e) { res.status(500).json({ error: e.message }); } }); app.patch('/api/personnages/:id', requireAuth, async (req, res) => { if (!NOCODB_PERSONNAGES_TABLE_ID) return res.status(400).json({ error: 'Table non configurée' }); try { const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_PERSONNAGES_TABLE_ID}/${req.params.id}`; const r = await fetch(url, { method: 'PATCH', headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify(req.body), }); res.status(r.status).json(await r.json()); } catch (e) { res.status(500).json({ error: e.message }); } }); app.delete('/api/personnages/:id', requireAuth, async (req, res) => { if (!NOCODB_PERSONNAGES_TABLE_ID) return res.status(400).json({ error: 'Table non configurée' }); try { const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_PERSONNAGES_TABLE_ID}/${req.params.id}`; const r = await fetch(url, { method: 'DELETE', headers: { 'xc-token': NOCODB_TOKEN } }); res.status(r.status).json({ ok: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ── Ressources API ── app.get('/api/ressources', requireAuth, async (req, res) => { if (!NOCODB_RESSOURCES_TABLE_ID) return res.json({ list: [] }); try { const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_RESSOURCES_TABLE_ID}?limit=200&sort=-CreatedAt`; const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } }); res.json(await r.json()); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/api/ressources', requireAuth, async (req, res) => { if (!NOCODB_RESSOURCES_TABLE_ID) return res.status(400).json({ error: 'Table non configurée' }); try { const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_RESSOURCES_TABLE_ID}`; const r = await fetch(url, { method: 'POST', headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify(req.body), }); res.status(r.status).json(await r.json()); } catch (e) { res.status(500).json({ error: e.message }); } }); app.patch('/api/ressources/:id', requireAuth, async (req, res) => { if (!NOCODB_RESSOURCES_TABLE_ID) return res.status(400).json({ error: 'Table non configurée' }); try { const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_RESSOURCES_TABLE_ID}/${req.params.id}`; const r = await fetch(url, { method: 'PATCH', headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify(req.body), }); res.status(r.status).json(await r.json()); } catch (e) { res.status(500).json({ error: e.message }); } }); app.delete('/api/ressources/:id', requireAuth, async (req, res) => { if (!NOCODB_RESSOURCES_TABLE_ID) return res.status(400).json({ error: 'Table non configurée' }); try { const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_RESSOURCES_TABLE_ID}/${req.params.id}`; const r = await fetch(url, { method: 'DELETE', headers: { 'xc-token': NOCODB_TOKEN } }); res.status(r.status).json({ ok: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Ressource image upload app.post('/api/ressource-upload', requireAuth, (req, res) => { try { const { dataUrl } = req.body; if (!dataUrl || !dataUrl.startsWith('data:image/')) return res.status(400).json({ error: 'Invalid image' }); const matches = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/); if (!matches) return res.status(400).json({ error: 'Invalid format' }); const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1]; const buffer = Buffer.from(matches[2], 'base64'); const uploadDir = path.join(__dirname, 'public', 'uploads'); if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); const filename = `res-${Date.now()}.${ext}`; fs.writeFileSync(path.join(uploadDir, filename), buffer); res.json({ path: `/uploads/${filename}?t=${Date.now()}` }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ── Hero image upload (file too large for NocoDB LongText) ── const fs = require('fs'); app.post('/api/hero-upload', requireAuth, (req, res) => { try { const { dataUrl } = req.body; if (!dataUrl || !dataUrl.startsWith('data:image/')) return res.status(400).json({ error: 'Invalid image' }); const matches = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/); if (!matches) return res.status(400).json({ error: 'Invalid format' }); const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1]; const buffer = Buffer.from(matches[2], 'base64'); const uploadDir = path.join(__dirname, 'public', 'uploads'); if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); const filename = `hero-${Date.now()}.${ext}`; fs.writeFileSync(path.join(uploadDir, filename), buffer); const publicPath = `/uploads/${filename}?t=${Date.now()}`; res.json({ path: publicPath }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Moodboard image upload app.post('/api/mood-upload', requireAuth, (req, res) => { try { const { dataUrl, index } = req.body; if (!dataUrl || !dataUrl.startsWith('data:image/')) return res.status(400).json({ error: 'Invalid image' }); const matches = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/); if (!matches) return res.status(400).json({ error: 'Invalid format' }); const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1]; const buffer = Buffer.from(matches[2], 'base64'); const uploadDir = path.join(__dirname, 'public', 'uploads'); if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); const filename = `mood-${index}-${Date.now()}.${ext}`; fs.writeFileSync(path.join(uploadDir, filename), buffer); res.json({ path: `/uploads/${filename}?t=${Date.now()}` }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Auth gate — redirect to login page if not authenticated app.get('/', (req, res) => { if (isAuth(req)) { res.sendFile(path.join(__dirname, 'public', 'index.html')); } else { res.sendFile(path.join(__dirname, 'public', 'login.html')); } }); // Static files (login.html is public, index.html is gated above) app.use(express.static(path.join(__dirname, 'public'), { index: false })); // ── Startup migration: ensure Tags column exists ── async function ensureTagsColumn() { try { const url = `${NOCODB_URL}/api/v2/meta/tables/${NOCODB_TABLE_ID}`; const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } }); const table = await r.json(); const hasTagsCol = (table.columns || []).some(c => c.title === 'Tags'); if (!hasTagsCol) { const createUrl = `${NOCODB_URL}/api/v2/meta/tables/${NOCODB_TABLE_ID}/columns`; const cr = await fetch(createUrl, { method: 'POST', headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ column_name: 'Tags', title: 'Tags', uidt: 'LongText' }), }); if (cr.ok) { console.log('Tags column created'); // Migrate existing Type values to Tags const recUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}?limit=200&fields=Id,Type`; const recR = await fetch(recUrl, { headers: { 'xc-token': NOCODB_TOKEN } }); const recData = await recR.json(); for (const rec of (recData.list || [])) { if (rec.Type) { const patchUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${rec.Id}`; await fetch(patchUrl, { method: 'PATCH', headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ Tags: rec.Type }), }); } } console.log('Type values migrated to Tags'); } } } catch (e) { console.error('Tags migration error:', e.message); } } // ── Startup migration: Statut → Tags (one-time) ── async function migrateStatutToTags() { try { const recUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}?limit=200&fields=Id,Statut,Tags`; const recR = await fetch(recUrl, { headers: { 'xc-token': NOCODB_TOKEN } }); const recData = await recR.json(); let migrated = 0; for (const rec of (recData.list || [])) { if (rec.Statut) { const existingTags = (rec.Tags || '').split(',').map(t => t.trim()).filter(Boolean); if (!existingTags.includes(rec.Statut)) { existingTags.push(rec.Statut); } const patchUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${rec.Id}`; await fetch(patchUrl, { method: 'PATCH', headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ Tags: existingTags.join(', '), Statut: null }), }); migrated++; } } if (migrated > 0) console.log(`Statut migrated to Tags for ${migrated} records`); } catch (e) { console.error('Statut migration error:', e.message); } } // ── Startup migration: ensure Personnages columns (Origines, Backstory) ── async function ensurePersonnagesColumns() { if (!NOCODB_PERSONNAGES_TABLE_ID) return; try { const url = `${NOCODB_URL}/api/v2/meta/tables/${NOCODB_PERSONNAGES_TABLE_ID}`; const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } }); const table = await r.json(); const existing = (table.columns || []).map(c => c.title); for (const col of ['Origines', 'Backstory']) { if (!existing.includes(col)) { const createUrl = `${NOCODB_URL}/api/v2/meta/tables/${NOCODB_PERSONNAGES_TABLE_ID}/columns`; await fetch(createUrl, { method: 'POST', headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ column_name: col, title: col, uidt: 'LongText' }), }); console.log(`Personnages column '${col}' created`); } } } catch (e) { console.error('Personnages columns migration error:', e.message); } } app.listen(PORT, () => { console.log(`TdB RdB — port ${PORT}`); if (NOCODB_TOKEN) { ensureTagsColumn(); migrateStatutToTags(); ensurePersonnagesColumns(); } });