Files
dashcaddy/dashcaddy-api/routes/updates.js
Hermes 283121edba Merge krystie-improvements into main
Resolves 24 conflicts between Hermes (DC-008/009/010 + response-helper
envelope standardization) and Krystie (DC-005 src/ refactor path fixes,
DC-006 TOTP integration, DC-007 new test suites, cloud backup
destinations).

Conflict resolutions:
- src/utils/logging.js:    took ours (consumers depend on logError/
                            safeErrorMessage/createLogger exports)
- src/config/site.js:      merged (her factored validateAndLogConfig +
                            applyConfigFields helpers)
- src/context/dns.js:      took hers (admin/readonly role iteration for
                            write operations)
- src/utilities/backup-
  manager.js:              took hers (Dropbox/WebDAV/SFTP cloud feature)
- status/dist/*, status/
  sw.js:                   took hers (minified bundles + newer SW cache)

Additional fix (post-merge regression):
- src/monitoring/health-checker.js: fixed DC-005 path miss —
  'require(./platform-paths)' → 'require(../../platform-paths)'

Test status: 921/922 passing. One known failure in logging.test.js
(async file-handle timing) tracked as follow-up.
2026-06-25 16:43:10 -07:00

175 lines
7.2 KiB
JavaScript

const express = require('express');
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
const { ValidationError } = require('../src/utilities/errors');
const { ok, successMessage } = require('../src/utils/responses');
/**
* Updates route factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.updateManager - Container update manager
* @param {Object} deps.selfUpdater - DashCaddy self-update manager
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Function} deps.logError - Error logging function
* @param {Function} deps.ok - Success response helper
* @returns {express.Router}
*/
module.exports = function({ updateManager, selfUpdater, asyncHandler, logError, ok }) {
const router = express.Router();
// ===== UPDATE MANAGEMENT ENDPOINTS =====
// Check for updates
router.post('/updates/check', asyncHandler(async (req, res) => {
await updateManager.checkForUpdates();
const updates = updateManager.getAvailableUpdates();
ok(res, { updates, count: updates.length });
}, 'updates-check'));
// Get available updates
router.get('/updates/available', asyncHandler(async (req, res) => {
const updates = updateManager.getAvailableUpdates();
const paginationParams = parsePaginationParams(req.query);
const result = paginate(updates, paginationParams);
ok(res, { updates: result.data, count: updates.length, ...(result.pagination && { pagination: result.pagination }) });
}, 'updates-available'));
// Update a container
router.post('/updates/update/:containerId', asyncHandler(async (req, res) => {
const result = await updateManager.updateContainer(req.params.containerId, req.body);
ok(res, { result });
}, 'updates-update'));
// Rollback update
router.post('/updates/rollback/:containerId', asyncHandler(async (req, res) => {
await updateManager.rollbackUpdate(req.params.containerId);
successMessage(res, 'Rollback completed');
}, 'updates-rollback'));
// Get update history
router.get('/updates/history', asyncHandler(async (req, res) => {
const paginationParams = parsePaginationParams(req.query);
// When paginating, fetch all history so pagination can slice correctly
const fetchLimit = paginationParams ? Number.MAX_SAFE_INTEGER : (parseInt(req.query.limit) || 50);
const history = updateManager.getHistory(fetchLimit);
const result = paginate(history, paginationParams);
ok(res, { history: result.data, ...(result.pagination && { pagination: result.pagination }) });
}, 'updates-history'));
// Configure auto-update
router.post('/updates/auto-update/:containerId', asyncHandler(async (req, res) => {
updateManager.configureAutoUpdate(req.params.containerId, req.body);
successMessage(res, 'Auto-update configured');
}, 'updates-auto-update'));
// Get auto-update configuration
router.get('/updates/auto-update', asyncHandler(async (req, res) => {
const config = updateManager.getAutoUpdateConfig();
ok(res, { config });
}, 'updates-auto-update-config'));
// Schedule update
router.post('/updates/schedule/:containerId', asyncHandler(async (req, res) => {
const { scheduledTime } = req.body;
if (!scheduledTime) {
throw new ValidationError('scheduledTime is required');
}
updateManager.scheduleUpdate(req.params.containerId, scheduledTime);
ok(res, { message: 'Update scheduled', scheduledTime });
}, 'updates-schedule'));
// ===== DASHCADDY SELF-UPDATE ENDPOINTS =====
// Get current version
router.get('/system/version', asyncHandler(async (req, res) => {
const local = selfUpdater.getLocalVersion();
ok(res, { name: 'DashCaddy', version: local.version, commit: local.commit });
}, 'system-version'));
// Check for DashCaddy update
router.get('/system/update-check', asyncHandler(async (req, res) => {
const result = await selfUpdater.checkForUpdate();
ok(res, result);
}, 'system-update-check'));
// Apply available update
router.post('/system/update-apply', asyncHandler(async (req, res) => {
const check = await selfUpdater.checkForUpdate();
if (!check.available) {
return successMessage(res, 'Already up to date');
}
// Refuse same-version applies. The check.available flag can theoretically be
// true with equal versions (commit-mismatch path); applying anyway just
// rebuilds the container without changing anything user-visible and pollutes
// history with v1.4.0 → v1.4.0 entries.
const localV = check.local && check.local.version;
const remoteV = check.remote && check.remote.version;
if (localV && remoteV && localV === remoteV) {
return ok(res, { message: 'Already up to date', version: localV });
}
// Start async — container may restart
selfUpdater.applyUpdate(check.remote).catch(err => {
logError('self-update', err);
});
ok(res, {
message: 'Update initiated',
fromVersion: localV,
toVersion: remoteV,
});
}, 'system-update-apply'));
// Notify endpoint — the publishing host POSTs here when a new release is
// out so the instance can update within seconds instead of waiting for the
// next 30-min poll. Auth is a shared secret in X-DashCaddy-Notify-Secret
// (per-instance, generated on first start, lives at
// <updatesDir>/notify-secret). This route is in the public-routes allowlist
// because TOTP would block machine-to-machine notifies.
router.post('/system/update-notify', asyncHandler(async (req, res) => {
const presented = req.get('X-DashCaddy-Notify-Secret') || '';
const expected = selfUpdater.getNotifySecret() || '';
// constant-time compare to avoid timing leaks
const presentedBuf = Buffer.from(presented);
const expectedBuf = Buffer.from(expected);
const secretOk = presentedBuf.length === expectedBuf.length &&
presentedBuf.length > 0 &&
require('crypto').timingSafeEqual(presentedBuf, expectedBuf);
if (!secretOk) {
return res.status(401).json({ success: false, error: 'Invalid notify secret' });
}
const result = selfUpdater.notifyAndApply('http-notify');
ok(res, result);
}, 'system-update-notify'));
// Get update status
router.get('/system/update-status', asyncHandler(async (req, res) => {
ok(res, {
status: selfUpdater.getStatus(),
lastCheck: selfUpdater.lastCheckTime,
lastResult: selfUpdater.lastCheckResult,
});
}, 'system-update-status'));
// Get self-update history
router.get('/system/update-history', asyncHandler(async (req, res) => {
const history = selfUpdater.getUpdateHistory();
ok(res, { history });
}, 'system-update-history'));
// List rollback versions
router.get('/system/rollback-versions', asyncHandler(async (req, res) => {
const versions = selfUpdater.getAvailableRollbacks();
ok(res, { versions });
}, 'system-rollback-versions'));
// Rollback to a previous version
router.post('/system/rollback', asyncHandler(async (req, res) => {
const { version } = req.body;
if (!version) throw new ValidationError('version is required');
selfUpdater.rollbackToVersion(version).catch(err => {
logError('self-rollback', err);
});
ok(res, { message: `Rollback to ${version} initiated` });
}, 'system-rollback'));
return router;
};