188 lines
5.3 KiB
JavaScript
188 lines
5.3 KiB
JavaScript
#!/usr/bin/env node
|
|
/* eslint-disable no-console */
|
|
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}`);
|
|
});
|