Files
cloudron-box/src/routes/cloudron.js
2022-09-23 13:09:07 +02:00

330 lines
13 KiB
JavaScript

'use strict';
exports = module.exports = {
login,
logout,
passwordResetRequest,
passwordReset,
setupAccount,
reboot,
isRebootRequired,
getConfig,
getDisks,
getMemory,
getUpdateInfo,
update,
checkForUpdates,
getLogs,
getLogStream,
updateDashboardDomain,
prepareDashboardDomain,
renewCerts,
getServerIpv4,
getServerIpv6,
getLanguages,
syncExternalLdap,
syncDnsRecords
};
const assert = require('assert'),
AuditSource = require('../auditsource.js'),
BoxError = require('../boxerror.js'),
cloudron = require('../cloudron.js'),
constants = require('../constants.js'),
debug = require('debug')('box:routes/cloudron'),
eventlog = require('../eventlog.js'),
externalLdap = require('../externalldap.js'),
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess,
safe = require('safetydance'),
speakeasy = require('speakeasy'),
sysinfo = require('../sysinfo.js'),
system = require('../system.js'),
tokens = require('../tokens.js'),
translation = require('../translation.js'),
updater = require('../updater.js'),
users = require('../users.js'),
updateChecker = require('../updatechecker.js');
async function login(req, res, next) {
assert.strictEqual(typeof req.user, 'object');
if ('type' in req.body && typeof req.body.type !== 'string') return next(new HttpError(400, 'type must be a string'));
const type = req.body.type || tokens.ID_WEBADMIN;
const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress || null;
const userAgent = req.headers['user-agent'] || '';
const auditSource = { authType: 'basic', ip };
let error = tokens.validateTokenType(type);
if (error) return next(new HttpError(400, error.message));
let token;
[error, token] = await safe(tokens.add({ clientId: type, identifier: req.user.id, expires: Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS }));
if (error) return next(new HttpError(500, error));
await eventlog.add(req.user.ghost ? eventlog.ACTION_USER_LOGIN_GHOST : eventlog.ACTION_USER_LOGIN, auditSource, { userId: req.user.id, user: users.removePrivateFields(req.user) });
if (!req.user.ghost) safe(users.notifyLoginLocation(req.user, ip, userAgent, auditSource), { debug });
next(new HttpSuccess(200, token));
}
async function logout(req, res) {
assert.strictEqual(typeof req.token, 'object');
await eventlog.add(eventlog.ACTION_USER_LOGOUT, AuditSource.fromRequest(req), { userId: req.user.id, user: users.removePrivateFields(req.user) });
await safe(tokens.delByAccessToken(req.token.accessToken));
res.redirect('/login.html');
}
async function passwordResetRequest(req, res, next) {
if (!req.body.identifier || typeof req.body.identifier !== 'string') return next(new HttpError(401, 'A identifier must be non-empty string'));
const [error] = await safe(users.sendPasswordResetByIdentifier(req.body.identifier, AuditSource.fromRequest(req)));
if (error && error.reason !== BoxError.NOT_FOUND) return next(BoxError.toHttpError(error));
next(new HttpSuccess(202, {}));
}
async function passwordReset(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
if (typeof req.body.resetToken !== 'string') return next(new HttpError(400, 'Missing resetToken'));
if (typeof req.body.password !== 'string') return next(new HttpError(400, 'Missing password'));
let [error, userObject] = await safe(users.getByResetToken(req.body.resetToken));
if (error) return next(new HttpError(401, 'Invalid resetToken'));
if (!userObject) return next(new HttpError(401, 'Invalid resetToken'));
if (userObject.twoFactorAuthenticationEnabled) {
if (typeof req.body.totpToken !== 'string') return next(new HttpError(401, 'A totpToken must be provided'));
const verified = speakeasy.totp.verify({ secret: userObject.twoFactorAuthenticationSecret, encoding: 'base32', token: req.body.totpToken, window: 2 });
if (!verified) return next(new HttpError(401, 'Invalid totpToken'));
}
// if you fix the duration here, the emails and UI have to be fixed as well
if (Date.now() - userObject.resetTokenCreationTime > 7 * 24 * 60 * 60 * 1000) return next(new HttpError(401, 'Token expired'));
if (!userObject.username) return next(new HttpError(409, 'No username set'));
// setPassword clears the resetToken
[error] = await safe(users.setPassword(userObject, req.body.password, AuditSource.fromRequest(req)));
if (error && error.reason === BoxError.BAD_FIELD) return next(new HttpError(400, error.message));
if (error) return next(BoxError.toHttpError(error));
let result;
[error, result] = await safe(tokens.add({ clientId: tokens.ID_WEBADMIN, identifier: userObject.id, expires: Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS }));
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(202, { accessToken: result.accessToken }));
}
async function setupAccount(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
if (!req.body.inviteToken || typeof req.body.inviteToken !== 'string') return next(new HttpError(400, 'inviteToken must be a non-empty string'));
if (!req.body.password || typeof req.body.password !== 'string') return next(new HttpError(400, 'password must be a non-empty string'));
// only sent if profile is not locked
if ('username' in req.body && typeof req.body.username !== 'string') return next(new HttpError(400, 'username must be a non-empty string'));
if ('displayName' in req.body && typeof req.body.displayName !== 'string') return next(new HttpError(400, 'displayName must be a non-empty string'));
const [error, userObject] = await safe(users.getByInviteToken(req.body.inviteToken));
if (error) return next(new HttpError(401, 'Invalid inviteToken'));
if (!userObject) return next(new HttpError(401, 'Invalid inviteToken'));
const [setupAccountError, accessToken] = await safe(users.setupAccount(userObject, req.body, AuditSource.fromRequest(req)));
if (setupAccountError) return next(BoxError.toHttpError(setupAccountError));
next(new HttpSuccess(201, { accessToken }));
}
async function reboot(req, res, next) {
// Finish the request, to let the appstore know we triggered the reboot
next(new HttpSuccess(202, {}));
await safe(cloudron.reboot());
}
async function isRebootRequired(req, res, next) {
const [error, rebootRequired] = await safe(cloudron.isRebootRequired());
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, { rebootRequired }));
}
async function getConfig(req, res, next) {
const [error, cloudronConfig] = await safe(cloudron.getConfig());
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, cloudronConfig));
}
async function getDisks(req, res, next) {
const [error, result] = await safe(system.getDisks());
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, result));
}
async function getMemory(req, res, next) {
const [error, result] = await safe(system.getMemory());
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, result));
}
async function update(req, res, next) {
if ('skipBackup' in req.body && typeof req.body.skipBackup !== 'boolean') return next(new HttpError(400, 'skipBackup must be a boolean'));
// this only initiates the update, progress can be checked via the progress route
const [error, taskId] = await safe(updater.updateToLatest(req.body, AuditSource.fromRequest(req)));
if (error && error.reason === BoxError.NOT_FOUND) return next(new HttpError(422, error.message));
if (error && error.reason === BoxError.BAD_STATE) return next(new HttpError(409, error.message));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(202, { taskId }));
}
function getUpdateInfo(req, res, next) {
next(new HttpSuccess(200, { update: updateChecker.getUpdateInfo() }));
}
async function checkForUpdates(req, res, next) {
// it can take a while sometimes to get all the app updates one by one
req.clearTimeout();
await updateChecker.checkForUpdates({ automatic: false });
next(new HttpSuccess(200, { update: updateChecker.getUpdateInfo() }));
}
async function getLogs(req, res, next) {
assert.strictEqual(typeof req.params.unit, 'string');
const lines = 'lines' in req.query ? parseInt(req.query.lines, 10) : 10; // we ignore last-event-id
if (isNaN(lines)) return next(new HttpError(400, 'lines must be a number'));
const options = {
lines: lines,
follow: false,
format: req.query.format || 'json'
};
const [error, logStream] = await safe(cloudron.getLogs(req.params.unit, options));
if (error) return next(BoxError.toHttpError(error));
res.writeHead(200, {
'Content-Type': 'application/x-logs',
'Content-Disposition': `attachment; filename="${req.params.unit}.log"`,
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no' // disable nginx buffering
});
logStream.pipe(res);
}
async function getLogStream(req, res, next) {
assert.strictEqual(typeof req.params.unit, 'string');
const lines = 'lines' in req.query ? parseInt(req.query.lines, 10) : 10; // we ignore last-event-id
if (isNaN(lines)) return next(new HttpError(400, 'lines must be a valid number'));
function sse(id, data) { return 'id: ' + id + '\ndata: ' + data + '\n\n'; }
if (req.headers.accept !== 'text/event-stream') return next(new HttpError(400, 'This API call requires EventStream'));
const options = {
lines: lines,
follow: true,
format: req.query.format || 'json'
};
const [error, logStream] = await safe(cloudron.getLogs(req.params.unit, options));
if (error) return next(BoxError.toHttpError(error));
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // disable nginx buffering
'Access-Control-Allow-Origin': '*'
});
res.write('retry: 3000\n');
res.on('close', logStream.close);
logStream.on('data', function (data) {
const obj = JSON.parse(data);
res.write(sse(obj.monotonicTimestamp, JSON.stringify(obj))); // send timestamp as id
});
logStream.on('end', res.end.bind(res));
logStream.on('error', res.end.bind(res, null));
}
async function updateDashboardDomain(req, res, next) {
if (!req.body.domain || typeof req.body.domain !== 'string') return next(new HttpError(400, 'domain must be a string'));
const [error] = await safe(cloudron.updateDashboardDomain(req.body.domain, AuditSource.fromRequest(req)));
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(204, {}));
}
async function prepareDashboardDomain(req, res, next) {
if (!req.body.domain || typeof req.body.domain !== 'string') return next(new HttpError(400, 'domain must be a string'));
const [error, taskId] = await safe(cloudron.prepareDashboardDomain(req.body.domain, AuditSource.fromRequest(req)));
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(202, { taskId }));
}
async function renewCerts(req, res, next) {
if ('domain' in req.body && typeof req.body.domain !== 'string') return next(new HttpError(400, 'domain must be a string'));
const [error, taskId] = await safe(cloudron.renewCerts({ domain: req.body.domain || null }, AuditSource.fromRequest(req)));
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(202, { taskId }));
}
async function syncExternalLdap(req, res, next) {
const [error, taskId] = await safe(externalLdap.startSyncer());
if (error) return next(new HttpError(500, error.message));
next(new HttpSuccess(202, { taskId }));
}
async function getServerIpv4(req, res, next) {
const [error, ipv4] = await safe(sysinfo.getServerIPv4());
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, { ipv4 }));
}
async function getServerIpv6(req, res, next) {
const [error, ipv6] = await safe(sysinfo.getServerIPv6()); // ignore any error
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, { ipv6 }));
}
async function getLanguages(req, res, next) {
const [error, languages] = await safe(translation.getLanguages());
if (error) return next(new BoxError.toHttpError(error));
next(new HttpSuccess(200, { languages }));
}
async function syncDnsRecords(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
if ('domain' in req.body && typeof req.body.domain !== 'string') return next(new HttpError(400, 'domain must be a string'));
if ('type' in req.body && typeof req.body.type !== 'string') return next(new HttpError(400, 'type must be a string'));
const [error, taskId] = await safe(cloudron.syncDnsRecords(req.body));
if (error && error.reason === BoxError.ACCESS_DENIED) return next(new HttpSuccess(200, { error: { reason: error.reason, message: error.message }}));
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(201, { taskId }));
}