2025-10-02 16:01:12 -06:00

133 lines
3.7 KiB
JavaScript

/**
* Biohazard VFX Micro-CMS API
*
* Installation:
* npm i express multer dotenv
*
* Usage:
* PORT=4000 node admin/api.js
* # or use PM2 for process management
*
* Reverse Proxy:
* Proxy /api/* to http://localhost:4000 via Nginx Proxy Manager.
*
* Security:
* Protect /admin/* and /api/* with basic-auth or Cloudflare Zero-Trust.
*
* SMB Share:
* Ensure new files are world-readable (0644) for Nginx.
*/
const express = require('express');
const multer = require('multer');
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const dotenv = require('dotenv');
dotenv.config();
const API_SECRET = process.env.API_SECRET;
const PORT = process.env.PORT || 4000;
const PROJECTS_JSON = path.resolve(__dirname, '../projects/projects.json');
const PROJECTS_DIR = path.resolve(__dirname, '../projects');
const TMP_DIR = path.resolve(__dirname, 'tmp');
if (!API_SECRET) {
console.error('Missing API_SECRET in environment.');
process.exit(1);
}
if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true });
const app = express();
app.use(express.json());
const upload = multer({ dest: TMP_DIR });
function slugify(str) {
return str
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 30);
}
function safeExt(filename) {
const ext = path.extname(filename).toLowerCase();
return ext.match(/\.(jpg|jpeg|png|webp|gif)$/) ? ext : '.jpg';
}
function requireApiKey(req, res, next) {
if (req.headers['x-api-key'] !== API_SECRET) {
return res.status(401).json({ ok: false, error: 'Unauthorized' });
}
next();
}
app.get('/api/projects', async (req, res) => {
try {
const data = await fsp.readFile(PROJECTS_JSON, 'utf8');
res.type('json').send(data);
} catch (e) {
res.status(500).json({ ok: false, error: 'Cannot read manifest.' });
}
});
app.post('/api/projects', requireApiKey, upload.single('thumb'), async (req, res) => {
try {
const { title, size = 'small', embed, credits = '', info = '' } = req.body;
const file = req.file;
if (!title || !embed || !file) {
if (file) fs.unlinkSync(file.path);
return res.status(400).json({ ok: false, error: 'Missing required fields.' });
}
// Load manifest
let manifest = [];
try {
manifest = JSON.parse(await fsp.readFile(PROJECTS_JSON, 'utf8'));
} catch (e) {}
const index = String(manifest.length + 1).padStart(2, '0');
const slug = slugify(title);
const folderName = `${index}_${slug}`;
const folderPath = path.join(PROJECTS_DIR, folderName);
await fsp.mkdir(folderPath, { recursive: true });
// Move thumbnail
const ext = safeExt(file.originalname);
const thumbName = `thumbnail${ext}`;
const thumbDest = path.join(folderPath, thumbName);
await fsp.rename(file.path, thumbDest);
await fsp.chmod(thumbDest, 0o644);
// Write info.txt and credits.txt with provided content
await fsp.writeFile(path.join(folderPath, 'info.txt'), info, { mode: 0o644 });
await fsp.writeFile(path.join(folderPath, 'credits.txt'), credits, { mode: 0o644 });
// Build new project object
const newObj = {
id: folderName,
title,
thumbnail: `projects/${folderName}/${thumbName}`,
size,
embed,
credits: `projects/${folderName}/credits.txt`,
info: `projects/${folderName}/info.txt`
};
manifest.push(newObj);
await fsp.writeFile(PROJECTS_JSON, JSON.stringify(manifest, null, 2), { mode: 0o644 });
res.json({ ok: true });
} catch (err) {
if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
res.status(500).json({ ok: false, error: 'Server error.' });
}
});
app.listen(PORT, () => {
console.log(`Biohazard VFX micro-CMS running on port ${PORT}`);
});