Files
tdb-rdb/server.js
T
2026-05-29 14:38:42 +02:00

296 lines
10 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 || '';
// 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
app.patch('/api/records/:id', requireAuth, async (req, res) => {
try {
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_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 });
}
});
// Proxy NocoDB — delete record
app.delete('/api/records/:id', requireAuth, async (req, res) => {
try {
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_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 });
}
});
// 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 });
}
});
// 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) {
// 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 });
}
});
// ── 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 }));
app.listen(PORT, () => console.log(`TdB RdB — port ${PORT}`));