Files
tdb-rdb/server.js
T
Etienne Delvarre 2c170f9ef2 Archives — panneau des fiches supprimées avec restauration
Bouton Archives dans la barre de filtres de la frise.
Panneau latéral listant les fiches supprimées avec aperçu complet.
Restauration recrée la fiche dans la frise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-03 15:34:27 +02:00

665 lines
27 KiB
JavaScript

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();
}
});