#!/usr/bin/env node const http = require('http'); const { execFile } = require('child_process'); const fs = require('fs'); const path = require('path'); const PORT = process.env.CONTROL_PORT || 8080; const TOKEN = process.env.CONTROL_TOKEN || ''; const FTP_ROOT = process.env.PHOTOBOOTH_ROOT || '/photobooth'; const FTP_SYSTEM_USER = process.env.FTP_SYSTEM_USER || 'ftpuser'; const FTP_SYSTEM_GROUP = process.env.FTP_SYSTEM_GROUP || 'ftpgroup'; const FTP_SYSTEM_UID = process.env.FTP_SYSTEM_UID || '2100'; const FTP_SYSTEM_GID = process.env.FTP_SYSTEM_GID || '2100'; const FTP_PW_CMD = process.env.PURE_PW_CMD || '/usr/sbin/pure-pw'; const jsonResponse = (res, status, payload) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(payload)); }; const unauthorized = (res) => jsonResponse(res, 401, { error: { code: 'unauthorized', message: 'Missing or invalid token' } }); const parseBody = (req) => new Promise((resolve, reject) => { let data = ''; req.on('data', (chunk) => { data += chunk; if (data.length > 1_000_000) { reject(new Error('Payload too large')); req.connection.destroy(); } }); req.on('end', () => { if (!data) return resolve({}); try { resolve(JSON.parse(data)); } catch { reject(new Error('Invalid JSON')); } }); }); const run = (cmd, args) => new Promise((resolve, reject) => { execFile(cmd, args, (error, stdout, stderr) => { if (error) { error.stdout = stdout; error.stderr = stderr; return reject(error); } resolve({ stdout, stderr }); }); }); const sanitizePath = (input) => { if (typeof input !== 'string' || !input.trim()) { throw new Error('path required'); } if (input.includes('..')) { throw new Error('invalid path'); } const cleaned = input.trim().replace(/^\/+/, ''); if (!cleaned) { throw new Error('invalid path'); } return cleaned; }; const sanitizeUsername = (input) => { if (typeof input !== 'string' || !input.trim()) { throw new Error('username required'); } const cleaned = input.trim(); if (!/^[a-zA-Z0-9_-]{3,64}$/.test(cleaned)) { throw new Error('invalid username'); } return cleaned; }; const requirePassword = (input) => { if (typeof input !== 'string' || input.length < 4) { throw new Error('password required'); } return input; }; const ensureDir = (absolutePath) => { fs.mkdirSync(absolutePath, { recursive: true }); }; const chownDir = (absolutePath) => new Promise((resolve, reject) => { fs.chown(absolutePath, Number(process.env.FTP_SYSTEM_UID || 2100), Number(process.env.FTP_SYSTEM_GID || 2100), (err) => { if (err) return reject(err); resolve(); }); }); const addUser = async ({ username, password, relPath }) => { const target = path.join(FTP_ROOT, relPath); ensureDir(target); await chownDir(target); const args = [ 'useradd', username, '-m', '-u', String(FTP_SYSTEM_UID), '-g', String(FTP_SYSTEM_GID), '-d', target, '-p', password, ]; await run(FTP_PW_CMD, args); }; const rotateUser = async ({ username, password }) => { const args = ['passwd', username, '-m', '-p', password]; await run(FTP_PW_CMD, args); }; const deleteUser = async ({ username }) => { const args = ['userdel', username, '-m']; await run(FTP_PW_CMD, args); }; const notFound = (res) => jsonResponse(res, 404, { error: { code: 'not_found', message: 'Route not found' } }); const server = http.createServer(async (req, res) => { if (TOKEN && req.headers.authorization !== `Bearer ${TOKEN}`) { return unauthorized(res); } try { if (req.method === 'GET' && req.url === '/health') { return jsonResponse(res, 200, { ok: true }); } if (req.method === 'POST' && req.url === '/users') { const body = await parseBody(req); const username = sanitizeUsername(body.username); const password = requirePassword(body.password); const relPath = sanitizePath(body.path); await addUser({ username, password, relPath }); return jsonResponse(res, 201, { ok: true }); } if (req.method === 'POST' && req.url.startsWith('/users/') && req.url.endsWith('/rotate')) { const username = sanitizeUsername(decodeURIComponent(req.url.split('/')[2] || '')); const body = await parseBody(req); const password = requirePassword(body.password); await rotateUser({ username, password }); return jsonResponse(res, 200, { ok: true }); } if (req.method === 'DELETE' && req.url.startsWith('/users/')) { const username = sanitizeUsername(decodeURIComponent(req.url.split('/')[2] || '')); try { await deleteUser({ username }); } catch (error) { if (error.stderr && /No such user/.test(error.stderr)) { return jsonResponse(res, 200, { ok: true, note: 'user_absent' }); } throw error; } return jsonResponse(res, 200, { ok: true }); } if (req.method === 'POST' && req.url === '/config') { return jsonResponse(res, 200, { ok: true }); } return notFound(res); } catch (error) { console.error('[control] error', error.message); return jsonResponse(res, 400, { error: { code: 'bad_request', message: error.message, }, }); } }); server.listen(PORT, '0.0.0.0', () => { console.log(`[control] listening on ${PORT}`); });