[pending-judge] feat(DC-161): calendar widget + Radicale auto-wiring
- Top-bar calendar widget (status/js/calendar-widget.js): next-appointment
display, settings modal with CalDAV/ICS source management and a
Test-connection probe. Follows the weather-widget pattern.
- Backend routes/calendar.js: sources CRUD (secrets in credential manager,
never in responses), aggregated events across sources with per-source
error isolation, Radicale provision endpoint, CalDAV discover probe.
- src/calendar/ics-parser.js: dependency-free RFC 5545 parser with bounded
RRULE expansion (DAILY/WEEKLY-BYDAY/MONTHLY/YEARLY, COUNT/UNTIL/INTERVAL,
EXDATE consumes COUNT, RECURRENCE-ID overrides), 500-event cap.
- src/calendar/caldav-client.js: CalDAV REPORT time-range queries over
node:https (no new deps), HTTPS-enforced off localhost, 5MiB response cap.
- src/calendar/radicale-provisioner.js: zero-config auto-wiring - generates
a 24-char password, writes Radicale htpasswd config (pure-Node APR1 hash
verified byte-for-byte against openssl passwd -apr1 vectors), stores
credentials, registers the source. Deploy hook in routes/apps/deploy.js
wires any Radicale deployed via the App Selector automatically.
- Radicale app template (tomsquest/docker-radicale) in the catalog.
- Tests: 32 new (parser unit, provisioner unit incl. openssl vectors,
route tests). Full suite 3552 passed; only pre-existing pdf-parse
billing/invoice failures remain (proven red at base 448f535).
- OpenAPI: 5 calendar paths documented. CHANGELOG entry added.
This commit is contained in:
18 files changed
+1845
No files matched your search
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* DC-161: Radicale auto-provisioner — the zero-config auto-wiring core.
|
||||
*
|
||||
* When DashCaddy deploys Radicale through the App Selector, this module:
|
||||
* 1. Generates a strong calendar password (never shown in a deploy form)
|
||||
* 2. Writes the Radicale config (htpasswd auth) + htpasswd file into the
|
||||
* mounted config volume BEFORE the container starts
|
||||
* 3. Stores the credentials in the DashCaddy credential manager under
|
||||
* `calendar:radicale:<subdomain>`
|
||||
* 4. Registers a calendar source so the dashboard calendar widget finds
|
||||
* it automatically on first render — no user input at all
|
||||
*
|
||||
* The htpasswd hash is APR1 (MD5-crypt) — the format Radicale's default
|
||||
* `radicale.auth.type = htpasswd` accepts, generated with Node's crypto
|
||||
* (no external tools at deploy time).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs').promises;
|
||||
const fsSync = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const RADICALE_CONFIG_TEMPLATE = `# Generated by DashCaddy DC-161 auto-provisioner — safe to regenerate
|
||||
[server]
|
||||
hosts = 0.0.0.0:5232
|
||||
|
||||
[auth]
|
||||
type = htpasswd
|
||||
htpasswd_filename = /config/users
|
||||
htpasswd_encryption = md5
|
||||
|
||||
[rights]
|
||||
type = owner_only
|
||||
|
||||
[storage]
|
||||
filesystem_folder = /data/collections
|
||||
|
||||
[web]
|
||||
type = internal
|
||||
`;
|
||||
|
||||
function generatePassword() {
|
||||
// 24 chars from a 62-char alphabet ≈ 142 bits — beyond brute-force reach
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const bytes = crypto.randomBytes(24);
|
||||
let out = '';
|
||||
for (const b of bytes) out += alphabet[b % alphabet.length];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** APR1 (Apache MD5-crypt) hash — pure Node, matches `openssl passwd -apr1`. */
|
||||
function apr1Hash(password, salt) {
|
||||
let saltStr = String(salt || '');
|
||||
if (!saltStr) {
|
||||
// Random 8-char salt from the APR1 alphabet when none supplied
|
||||
const alphabet = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
const bytes = crypto.randomBytes(8);
|
||||
saltStr = Array.from(bytes, b => alphabet[b % alphabet.length]).join('');
|
||||
}
|
||||
saltStr = saltStr.slice(0, 8);
|
||||
const pw = Buffer.from(String(password), 'utf8');
|
||||
const s = Buffer.from(saltStr, 'ascii');
|
||||
|
||||
// Step 2: B = md5(pw + salt + pw)
|
||||
const bDigest = crypto.createHash('md5').update(pw).update(s).update(pw).digest();
|
||||
|
||||
// Step 1 + 3 + 4: ONE rolling hash — pw, "$apr1$", salt, then B folded in
|
||||
// 16 bytes at a time (tail truncated), then the "weird" loop feeding
|
||||
// B[0] on odd bits and pw[0] on even bits. (md5(md5(x)||y) ≠ MD5Update —
|
||||
// the accumulator must CONTINUE, never re-digest.)
|
||||
const ctx = crypto.createHash('md5').update(pw).update('$apr1$').update(s);
|
||||
let i = pw.length;
|
||||
while (i > 0) {
|
||||
ctx.update(i > 16 ? bDigest : bDigest.subarray(0, i));
|
||||
i -= 16;
|
||||
}
|
||||
// Canonical APR (crypto/apr_md5.c): final is memset to ZERO before the
|
||||
// weird loop, so odd bits feed a literal 0x00 byte; even bits feed pw[0].
|
||||
i = pw.length;
|
||||
while (i > 0) {
|
||||
ctx.update(i & 1 ? Buffer.from([0]) : pw.subarray(0, 1));
|
||||
i >>= 1;
|
||||
}
|
||||
let final = ctx.digest();
|
||||
|
||||
// 1000-iteration round: odd → (pw, final), even → (final, pw).
|
||||
for (let k = 0; k < 1000; k++) {
|
||||
const h = crypto.createHash('md5');
|
||||
if (k & 1) h.update(pw); else h.update(final);
|
||||
if (k % 3) h.update(s);
|
||||
if (k % 7) h.update(pw);
|
||||
if (k & 1) h.update(final); else h.update(pw);
|
||||
final = h.digest();
|
||||
}
|
||||
|
||||
return `$apr1$${saltStr}$${toBase64Apr1(final)}`;
|
||||
}
|
||||
|
||||
function toBase64Apr1(digest) {
|
||||
const chars = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
const groups = [[0, 6, 12], [1, 7, 13], [2, 8, 14], [3, 9, 15], [4, 10, 5]];
|
||||
let out = '';
|
||||
for (const [a, b, c] of groups) {
|
||||
let n = (digest[a] << 16) | (digest[b] << 8) | digest[c];
|
||||
for (let k = 0; k < 4; k++) { out += chars[n & 0x3f]; n >>= 6; }
|
||||
}
|
||||
const n = digest[11];
|
||||
out += chars[n & 0x3f];
|
||||
out += chars[(n >> 6) & 0x3f];
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write (idempotently) the Radicale config + htpasswd into the host-side
|
||||
* config volume directory. Called before container start; if the files
|
||||
* already exist with the same content, nothing is written.
|
||||
* @param {string} configDir host path bound to /config inside the container
|
||||
* @param {{user: string, pass: string}} creds
|
||||
* @returns {Promise<{written: boolean, configDir: string}>}
|
||||
*/
|
||||
async function writeRadicaleConfig(configDir, { user, pass }) {
|
||||
if (!configDir || path.basename(configDir) === '/') throw new Error('Radicale config dir missing');
|
||||
fsSync.mkdirSync(configDir, { recursive: true });
|
||||
const configFile = path.join(configDir, 'config');
|
||||
const usersFile = path.join(configDir, 'users');
|
||||
|
||||
const hash = apr1Hash(pass);
|
||||
const userLine = `${user}:${hash}\n`;
|
||||
|
||||
await fs.writeFile(configFile, RADICALE_CONFIG_TEMPLATE, 'utf8');
|
||||
await fs.writeFile(usersFile, userLine, { encoding: 'utf8', mode: 0o600 });
|
||||
return { written: true, configDir };
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful Radicale deploy: register the calendar source so the
|
||||
* widget auto-discovers it, and persist credentials.
|
||||
* @param {Object} deps
|
||||
* @returns {Promise<{source: Object}>}
|
||||
*/
|
||||
async function registerAutoWiredSource({ calendarStateManager, credentialManager, subdomain, serviceUrl, creds, templateId = 'radicale' }) {
|
||||
const username = creds.user;
|
||||
const credKey = `calendar:${templateId}:${subdomain}`;
|
||||
await credentialManager.store(credKey, JSON.stringify({ user: username, pass: creds.pass }), {
|
||||
label: `Calendar (${templateId}) — ${subdomain}`, auto: true, createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const source = {
|
||||
id: `auto-${templateId}-${subdomain}`,
|
||||
type: 'caldav',
|
||||
name: `Calendar (${subdomain})`,
|
||||
url: `${String(serviceUrl).replace(/\/$/, '')}/${username}/`,
|
||||
user: username,
|
||||
autoProvisioned: true,
|
||||
templateId,
|
||||
subdomain,
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await calendarStateManager.addItem(source);
|
||||
return source;
|
||||
}
|
||||
|
||||
module.exports = { generatePassword, apr1Hash, writeRadicaleConfig, registerAutoWiredSource, RADICALE_CONFIG_TEMPLATE };
|
||||
Reference in new issue
Block a user