// Config Generator for ntfy // // How it works // ------------ // The generator is a modal with a left panel (form inputs) and a right panel (live output). // On every input change, the update cycle runs: updateVisibility() syncs the UI state, then // updateOutput() collects values from the form and renders them as server.yml, docker-compose.yml, // or env vars. // // The CONFIG array is the source of truth for which config keys exist, their env var names, // which section they belong to, and optional defaults. collectValues() walks CONFIG, reads each // matching DOM element, skips anything in a hidden panel or section, and returns a plain // {key: value} object. The three generators (generateServerYml, generateDockerCompose, // generateEnvVars) each iterate CONFIG in order and format the collected values. Provisioned // users, ACLs, and tokens are collected separately from repeatable rows and stored as arrays // under "_auth-users", "_auth-acls", "_auth-tokens". The formatAuthUsers/Acls/Tokens() helpers // turn those arrays into "user:pass:role" strings shared by all three generators. // // Visibility is managed by updateVisibility(), which delegates to five helpers: // syncRadiosToHiddenInputs() copies user-facing radios and selects to hidden inputs that CONFIG // knows about (e.g. login mode radio → enable-login + require-login checkboxes). // updateFeatureVisibility() shows/hides nav tabs, configure buttons, and email sections based // on which feature checkboxes are checked. updatePostgresFields() swaps file-path inputs for // "Using PostgreSQL" labels when PostgreSQL is selected. prefillDefaults() sets sensible values // (file paths, addresses) when a feature is first enabled, tracked via a data-cleared attribute // so user edits are respected. autoDetectServerType() flips the server-type radio to "custom" // if the user's access/login settings no longer match "open" or "private". // // Event listeners are grouped into setup functions (setupModalEvents, setupAuthEvents, // setupServerTypeEvents, setupFormListeners, setupWebPushEvents) called from initGenerator(). // A general listener on all inputs calls the update cycle. Specific listeners handle cleanup // logic, e.g. unchecking auth resets all auth-related fields and provisioned rows. // // Frequently-used DOM elements are queried once in cacheElements() and passed around as an // `els` object, avoiding repeated querySelector calls. // // Field inter-dependencies // ------------------------ // Several UI fields don't map 1:1 to config keys. Instead, user-friendly controls drive // hidden inputs that CONFIG knows about. The sync happens in syncRadiosToHiddenInputs(), // called on every change via updateVisibility(). // // Server type (Open / Private / Custom) // "Open" → unchecks auth, sets default-access to read-write, login to disabled // "Private" → checks auth, sets default-access to deny-all, login to required // "Custom" → no automatic changes; also auto-selected when the user manually // changes access/login to values that don't match Open or Private // // Auth checkbox (#cg-feat-auth) // When unchecked → resets: default-access to read-write, login to disabled, // signup to no, UnifiedPush to no, removes all provisioned users/ACLs/tokens, // clears auth-file, switches server type back to Open. // Also explicitly unchecks hidden enable-login, require-login, enable-signup. // When checked by PostgreSQL auto-enable → no reset, just enables the tab. // // Login mode (Disabled / Enabled / Required) — three-way radio // Maps to two hidden checkboxes: // enable-login = checked when Enabled OR Required // require-login = checked when Required only // // Signup (Yes / No) — radio pair // Maps to hidden enable-signup checkbox. // // Proxy (Yes / No) — radio pair // Maps to hidden behind-proxy checkbox. // // iOS support (Yes / No) — radio pair // Sets upstream-base-url to "https://ntfy.sh" when Yes, clears when No. // // UnifiedPush (Yes / No) — radio pair // When Yes, prepends a "*:up*:write-only" ACL entry at collection time. // // Database type (SQLite / PostgreSQL) // When PostgreSQL is selected: // - Auto-enables auth if not already on // - Hides file-path fields (auth-file, cache-file, web-push-file) and shows // "Using PostgreSQL" labels instead // - Shows the Database nav tab for the database-url field // - Prefills database-url with a postgres:// template // The database question itself only appears when a DB-dependent feature // (auth, cache, or web push) is enabled. // // Feature checkboxes (auth, cache, attachments, web push, email out, email in) // Each shows/hides its nav tab and "Configure" button. // When first enabled, prefillDefaults() fills in sensible paths/values. // The prefill is skipped if the user has already typed (or cleared) the field // (tracked via data-cleared attribute). // (function () { "use strict"; const CONFIG = [ { key: "base-url", env: "NTFY_BASE_URL", section: "basic" }, { key: "behind-proxy", env: "NTFY_BEHIND_PROXY", section: "basic", type: "bool" }, { key: "database-url", env: "NTFY_DATABASE_URL", section: "database" }, { key: "auth-file", env: "NTFY_AUTH_FILE", section: "auth" }, { key: "auth-default-access", env: "NTFY_AUTH_DEFAULT_ACCESS", section: "auth", def: "read-write" }, { key: "enable-login", env: "NTFY_ENABLE_LOGIN", section: "auth", type: "bool" }, { key: "require-login", env: "NTFY_REQUIRE_LOGIN", section: "auth", type: "bool" }, { key: "enable-signup", env: "NTFY_ENABLE_SIGNUP", section: "auth", type: "bool" }, { key: "attachment-cache-dir", env: "NTFY_ATTACHMENT_CACHE_DIR", section: "attach" }, { key: "attachment-file-size-limit", env: "NTFY_ATTACHMENT_FILE_SIZE_LIMIT", section: "attach", def: "15M" }, { key: "attachment-total-size-limit", env: "NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT", section: "attach", def: "5G" }, { key: "attachment-expiry-duration", env: "NTFY_ATTACHMENT_EXPIRY_DURATION", section: "attach", def: "3h" }, { key: "cache-file", env: "NTFY_CACHE_FILE", section: "cache" }, { key: "cache-duration", env: "NTFY_CACHE_DURATION", section: "cache", def: "12h" }, { key: "web-push-public-key", env: "NTFY_WEB_PUSH_PUBLIC_KEY", section: "webpush" }, { key: "web-push-private-key", env: "NTFY_WEB_PUSH_PRIVATE_KEY", section: "webpush" }, { key: "web-push-file", env: "NTFY_WEB_PUSH_FILE", section: "webpush" }, { key: "web-push-email-address", env: "NTFY_WEB_PUSH_EMAIL_ADDRESS", section: "webpush" }, { key: "smtp-sender-addr", env: "NTFY_SMTP_SENDER_ADDR", section: "smtp-out" }, { key: "smtp-sender-from", env: "NTFY_SMTP_SENDER_FROM", section: "smtp-out" }, { key: "smtp-sender-user", env: "NTFY_SMTP_SENDER_USER", section: "smtp-out" }, { key: "smtp-sender-pass", env: "NTFY_SMTP_SENDER_PASS", section: "smtp-out" }, { key: "smtp-server-listen", env: "NTFY_SMTP_SERVER_LISTEN", section: "smtp-in" }, { key: "smtp-server-domain", env: "NTFY_SMTP_SERVER_DOMAIN", section: "smtp-in" }, { key: "smtp-server-addr-prefix", env: "NTFY_SMTP_SERVER_ADDR_PREFIX", section: "smtp-in" }, { key: "upstream-base-url", env: "NTFY_UPSTREAM_BASE_URL", section: "upstream" }, ]; // Feature checkbox → nav tab ID const NAV_MAP = { "cg-feat-auth": "cg-nav-auth", "cg-feat-cache": "cg-nav-cache", "cg-feat-attach": "cg-nav-attach", "cg-feat-webpush": "cg-nav-webpush", }; const SECTION_COMMENTS = { basic: "# Server", database: "# Database", auth: "# Access control", attach: "# Attachments", cache: "# Message cache", webpush: "# Web push", "smtp-out": "# Email notifications (outgoing)", "smtp-in": "# Email publishing (incoming)", upstream: "# Upstream", }; const durationRegex = /^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$/i; const sizeRegex = /^(\d+)([tgmkb])?$/i; // --- DOM cache --- function cacheElements(modal) { return { modal, authCheckbox: modal.querySelector("#cg-feat-auth"), cacheCheckbox: modal.querySelector("#cg-feat-cache"), attachCheckbox: modal.querySelector("#cg-feat-attach"), webpushCheckbox: modal.querySelector("#cg-feat-webpush"), smtpOutCheckbox: modal.querySelector("#cg-feat-smtp-out"), smtpInCheckbox: modal.querySelector("#cg-feat-smtp-in"), accessSelect: modal.querySelector("#cg-default-access-select"), accessHidden: modal.querySelector('input[type="hidden"][data-key="auth-default-access"]'), loginHidden: modal.querySelector("#cg-enable-login-hidden"), requireLoginHidden: modal.querySelector("#cg-require-login-hidden"), signupHidden: modal.querySelector("#cg-enable-signup-hidden"), proxyCheckbox: modal.querySelector("#cg-behind-proxy"), dbStep: modal.querySelector("#cg-wizard-db"), navDb: modal.querySelector("#cg-nav-database"), navEmail: modal.querySelector("#cg-nav-email"), emailOutSection: modal.querySelector("#cg-email-out-section"), emailInSection: modal.querySelector("#cg-email-in-section"), codeEl: modal.querySelector("#cg-code"), warningsEl: modal.querySelector("#cg-warnings"), }; } // --- Collect values --- function collectValues(els) { const { modal } = els; const values = {}; CONFIG.forEach((c) => { const el = modal.querySelector(`[data-key="${c.key}"]`); if (!el) return; // Skip fields in hidden panels (feature not enabled) const panel = el.closest(".cg-panel"); if (panel) { // Panel hidden directly if (panel.style.display === "none" || panel.classList.contains("cg-hidden")) return; // Panel with a nav tab that is hidden (feature not enabled) if (!panel.classList.contains("active")) { const panelId = panel.id; const navTab = modal.querySelector(`[data-panel="${panelId}"]`); if (!navTab || navTab.classList.contains("cg-hidden")) return; } } // Skip file inputs replaced by PostgreSQL if (el.dataset.pgDisabled) return; // Skip hidden individual fields or sections let ancestor = el.parentElement; while (ancestor && ancestor !== modal) { if (ancestor.style.display === "none" || ancestor.classList.contains("cg-hidden")) return; ancestor = ancestor.parentElement; } let val; if (c.type === "bool") { if (el.checked) val = "true"; } else { val = el.value.trim(); if (!val) return; } if (val && c.def && val === c.def) return; if (val) values[c.key] = val; }); // Provisioned users const users = collectRepeatableRows(modal, ".cg-auth-user-row", (row) => { const u = row.querySelector('[data-field="username"]'); const p = row.querySelector('[data-field="password"]'); const r = row.querySelector('[data-field="role"]'); if (u && p && u.value.trim() && p.value.trim()) { return { username: u.value.trim(), password: p.value.trim(), role: r ? r.value : "user" }; } return null; }); if (users.length) values["_auth-users"] = users; // Provisioned ACLs const acls = collectRepeatableRows(modal, ".cg-auth-acl-row", (row) => { const u = row.querySelector('[data-field="username"]'); const t = row.querySelector('[data-field="topic"]'); const p = row.querySelector('[data-field="permission"]'); if (u && t && t.value.trim()) { return { user: u.value.trim(), topic: t.value.trim(), permission: p ? p.value : "read-write" }; } return null; }); if (acls.length) values["_auth-acls"] = acls; // Provisioned tokens const tokens = collectRepeatableRows(modal, ".cg-auth-token-row", (row) => { const u = row.querySelector('[data-field="username"]'); const t = row.querySelector('[data-field="token"]'); const l = row.querySelector('[data-field="label"]'); if (u && t && u.value.trim() && t.value.trim()) { return { user: u.value.trim(), token: t.value.trim(), label: l ? l.value.trim() : "" }; } return null; }); if (tokens.length) values["_auth-tokens"] = tokens; // UnifiedPush ACL const upYes = modal.querySelector('input[name="cg-unifiedpush"][value="yes"]'); if (upYes && upYes.checked) { if (!values["_auth-acls"]) values["_auth-acls"] = []; values["_auth-acls"].unshift({ user: "*", topic: "up*", permission: "write-only" }); } return values; } function collectRepeatableRows(modal, selector, extractor) { const results = []; modal.querySelectorAll(selector).forEach((row) => { const item = extractor(row); if (item) results.push(item); }); return results; } // --- Shared auth formatting --- function formatAuthUsers(values) { if (!values["_auth-users"]) return null; return values["_auth-users"].map((u) => `${u.username}:${u.password}:${u.role}`); } function formatAuthAcls(values) { if (!values["_auth-acls"]) return null; return values["_auth-acls"].map((a) => `${a.user || "*"}:${a.topic}:${a.permission}`); } function formatAuthTokens(values) { if (!values["_auth-tokens"]) return null; return values["_auth-tokens"].map((t) => t.label ? `${t.user}:${t.token}:${t.label}` : `${t.user}:${t.token}`); } // --- Output generators --- function generateServerYml(values) { const lines = []; let lastSection = ""; let hadAuth = false; CONFIG.forEach((c) => { if (!(c.key in values)) return; if (c.section !== lastSection) { if (lines.length) lines.push(""); if (SECTION_COMMENTS[c.section]) lines.push(SECTION_COMMENTS[c.section]); lastSection = c.section; } if (c.section === "auth") hadAuth = true; const val = values[c.key]; lines.push(c.type === "bool" ? `${c.key}: true` : `${c.key}: "${val}"`); }); // Find where auth section ends to insert users/acls/tokens there let authInsertIdx = lines.length; if (hadAuth) { for (let i = 0; i < lines.length; i++) { if (lines[i] === "# Access control") { // Find the end of this section (next section comment or end) for (let j = i + 1; j < lines.length; j++) { if (lines[j].startsWith("# ")) { authInsertIdx = j - 1; break; } authInsertIdx = j + 1; } break; } } } const authExtra = []; const users = formatAuthUsers(values); if (users) { if (!hadAuth) { authExtra.push(""); authExtra.push("# Access control"); hadAuth = true; } authExtra.push("auth-users:"); users.forEach((entry) => authExtra.push(` - "${entry}"`)); } const acls = formatAuthAcls(values); if (acls) { if (!hadAuth) { authExtra.push(""); authExtra.push("# Access control"); hadAuth = true; } authExtra.push("auth-access:"); acls.forEach((entry) => authExtra.push(` - "${entry}"`)); } const tokens = formatAuthTokens(values); if (tokens) { if (!hadAuth) { authExtra.push(""); authExtra.push("# Access control"); hadAuth = true; } authExtra.push("auth-tokens:"); tokens.forEach((entry) => authExtra.push(` - "${entry}"`)); } // Splice auth extras into the right position if (authExtra.length) { lines.splice(authInsertIdx, 0, ...authExtra); } return lines.join("\n"); } function generateDockerCompose(values) { const lines = [ "services:", " ntfy:", " image: binwiederhier/ntfy", " command: serve", " environment:", ]; CONFIG.forEach((c) => { if (!(c.key in values)) return; let val = c.type === "bool" ? "true" : values[c.key]; if (val.includes("$")) { val = val.replace(/\$/g, "$$$$"); lines.push(" # Note: $ is doubled to $$ for docker-compose"); } lines.push(` ${c.env}: ${val}`); }); const users = formatAuthUsers(values); if (users) { let usersVal = users.join(","); usersVal = usersVal.replace(/\$/g, "$$$$"); lines.push(" # Note: $ is doubled to $$ for docker-compose"); lines.push(` NTFY_AUTH_USERS: ${usersVal}`); } const acls = formatAuthAcls(values); if (acls) { lines.push(` NTFY_AUTH_ACCESS: ${acls.join(",")}`); } const tokens = formatAuthTokens(values); if (tokens) { lines.push(` NTFY_AUTH_TOKENS: ${tokens.join(",")}`); } lines.push( " volumes:", " - /var/cache/ntfy:/var/cache/ntfy", " - /etc/ntfy:/etc/ntfy", " ports:", ' - "80:80"', " restart: unless-stopped", ); return lines.join("\n"); } function generateEnvVars(values) { const lines = []; CONFIG.forEach((c) => { if (!(c.key in values)) return; const val = c.type === "bool" ? "true" : values[c.key]; const q = val.includes("$") ? "'" : '"'; lines.push(`${c.env}=${q}${val}${q}`); }); const users = formatAuthUsers(values); if (users) { const usersStr = users.join(","); const q = usersStr.includes("$") ? "'" : '"'; lines.push(`NTFY_AUTH_USERS=${q}${usersStr}${q}`); } const acls = formatAuthAcls(values); if (acls) { lines.push(`NTFY_AUTH_ACCESS="${acls.join(",")}"`); } const tokens = formatAuthTokens(values); if (tokens) { lines.push(`NTFY_AUTH_TOKENS="${tokens.join(",")}"`); } return lines.join("\n"); } // --- Web Push VAPID key generation (P-256 ECDH) --- function generateVAPIDKeys() { return crypto.subtle.generateKey( { name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"] ).then((keyPair) => { return Promise.all([ crypto.subtle.exportKey("raw", keyPair.publicKey), crypto.subtle.exportKey("pkcs8", keyPair.privateKey) ]); }).then((keys) => { const pubBytes = new Uint8Array(keys[0]); const privPkcs8 = new Uint8Array(keys[1]); // Extract raw 32-byte private key from PKCS#8 (last 32 bytes of the DER) const privBytes = privPkcs8.slice(privPkcs8.length - 32); return { publicKey: arrayToBase64Url(pubBytes), privateKey: arrayToBase64Url(privBytes) }; }); } function arrayToBase64Url(arr) { let str = ""; for (let i = 0; i < arr.length; i++) { str += String.fromCharCode(arr[i]); } return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } // --- Output + validation --- function updateOutput(els) { const { modal, codeEl, warningsEl } = els; if (!codeEl) return; const values = collectValues(els); const activeTab = modal.querySelector(".cg-output-tab.active"); const format = activeTab ? activeTab.getAttribute("data-format") : "server-yml"; const hasValues = Object.keys(values).length > 0; if (!hasValues) { codeEl.innerHTML = 'Configure options on the left to generate your config...'; setHidden(warningsEl, true); return; } let output; if (format === "docker-compose") { output = generateDockerCompose(values); } else if (format === "env-vars") { output = generateEnvVars(values); } else { output = generateServerYml(values); } codeEl.textContent = output; // Validation warnings const warnings = validate(values); if (warningsEl) { if (warnings.length) { warningsEl.innerHTML = warnings.map((w) => `
${w}
`).join(""); } setHidden(warningsEl, !warnings.length); } } function validate(values) { const warnings = []; const baseUrl = values["base-url"] || ""; // base-url format if (baseUrl) { if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) { warnings.push("base-url must start with http:// or https://"); } else { try { const u = new URL(baseUrl); if (u.pathname !== "/" && u.pathname !== "") { warnings.push("base-url must not have a path, ntfy does not support sub-paths"); } } catch (e) { warnings.push("base-url is not a valid URL"); } } } // database-url must start with postgres:// if (values["database-url"] && !values["database-url"].startsWith("postgres://")) { warnings.push("database-url must start with postgres://"); } // Web push requires all fields + base-url const wpPublic = values["web-push-public-key"]; const wpPrivate = values["web-push-private-key"]; const wpEmail = values["web-push-email-address"]; const wpFile = values["web-push-file"]; const dbUrl = values["database-url"]; if (wpPublic || wpPrivate || wpEmail) { const missing = []; if (!wpPublic) missing.push("web-push-public-key"); if (!wpPrivate) missing.push("web-push-private-key"); if (!wpFile && !dbUrl) missing.push("web-push-file or database-url"); if (!wpEmail) missing.push("web-push-email-address"); if (!baseUrl) missing.push("base-url"); if (missing.length) { warnings.push(`Web push requires: ${missing.join(", ")}`); } } // SMTP sender requires base-url and smtp-sender-from if (values["smtp-sender-addr"]) { const smtpMissing = []; if (!baseUrl) smtpMissing.push("base-url"); if (!values["smtp-sender-from"]) smtpMissing.push("smtp-sender-from"); if (smtpMissing.length) { warnings.push(`Email sending requires: ${smtpMissing.join(", ")}`); } } // SMTP server requires domain if (values["smtp-server-listen"] && !values["smtp-server-domain"]) { warnings.push("Email publishing requires smtp-server-domain"); } // Attachments require base-url if (values["attachment-cache-dir"] && !baseUrl) { warnings.push("Attachments require base-url to be set"); } // Upstream requires base-url and can't equal it if (values["upstream-base-url"]) { if (!baseUrl) { warnings.push("Upstream server requires base-url to be set"); } else if (baseUrl === values["upstream-base-url"]) { warnings.push("base-url and upstream-base-url cannot be the same"); } } // enable-signup requires enable-login if (values["enable-signup"] && !values["enable-login"]) { warnings.push("Enable signup requires enable-login to also be set"); } // Duration field validation [ { key: "cache-duration", label: "Cache duration" }, { key: "attachment-expiry-duration", label: "Attachment expiry duration" }, ].forEach((f) => { if (values[f.key] && !durationRegex.test(values[f.key])) { warnings.push(`${f.label} must be a valid duration (e.g. 12h, 3d, 30m, 60s)`); } }); // Size field validation [ { key: "attachment-file-size-limit", label: "Attachment file size limit" }, { key: "attachment-total-size-limit", label: "Attachment total size limit" }, ].forEach((f) => { if (values[f.key] && !sizeRegex.test(values[f.key])) { warnings.push(`${f.label} must be a valid size (e.g. 15M, 5G, 100K)`); } }); return warnings; } // --- Helpers --- function generateToken() { const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; let token = "tk_"; for (let i = 0; i < 29; i++) { token += chars.charAt(Math.floor(Math.random() * chars.length)); } return token; } function generatePassword() { const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; let password = ""; for (let i = 0; i < 16; i++) { password += chars.charAt(Math.floor(Math.random() * chars.length)); } return password; } function prefill(modal, key, value) { const el = modal.querySelector(`[data-key="${key}"]`); if (el && !el.value.trim() && !el.dataset.cleared) el.value = value; } function switchPanel(modal, panelId) { modal.querySelectorAll(".cg-nav-tab").forEach((t) => t.classList.remove("active")); modal.querySelectorAll(".cg-panel").forEach((p) => p.classList.remove("active")); const navTab = modal.querySelector(`[data-panel="${panelId}"]`); const panel = modal.querySelector(`#${panelId}`); if (navTab) navTab.classList.add("active"); if (panel) panel.classList.add("active"); } function setHidden(el, hidden) { if (!el) return; if (hidden) { el.classList.add("cg-hidden"); } else { el.classList.remove("cg-hidden"); } } // --- Visibility: broken into focused helpers --- function syncRadiosToHiddenInputs(els) { const { modal, accessSelect, accessHidden, loginHidden, requireLoginHidden, signupHidden, proxyCheckbox } = els; // Proxy radio → hidden checkbox const proxyYes = modal.querySelector('input[name="cg-proxy"][value="yes"]'); if (proxyYes && proxyCheckbox) { proxyCheckbox.checked = proxyYes.checked; } // Default access select → hidden input if (accessSelect && accessHidden) { accessHidden.value = accessSelect.value; } // Login mode three-way toggle → hidden checkboxes const loginMode = modal.querySelector('input[name="cg-login-mode"]:checked'); const loginModeVal = loginMode ? loginMode.value : "disabled"; if (loginHidden) loginHidden.checked = (loginModeVal === "enabled" || loginModeVal === "required"); if (requireLoginHidden) requireLoginHidden.checked = (loginModeVal === "required"); const signupYes = modal.querySelector('input[name="cg-enable-signup"][value="yes"]'); if (signupYes && signupHidden) signupHidden.checked = signupYes.checked; return loginModeVal; } function updateFeatureVisibility(els, flags) { const { modal, dbStep, navDb, navEmail, emailOutSection, emailInSection } = els; const { authEnabled, cacheEnabled, webpushEnabled, smtpOutEnabled, smtpInEnabled, needsDb, isPostgres } = flags; // Show database question only if a DB-dependent feature is selected setHidden(dbStep, !needsDb); // Nav tabs for features for (const featId in NAV_MAP) { const checkbox = modal.querySelector(`#${featId}`); const navTab = modal.querySelector(`#${NAV_MAP[featId]}`); if (checkbox && navTab) { setHidden(navTab, !checkbox.checked); } } // Email tab — show if either outgoing or incoming is enabled setHidden(navEmail, !smtpOutEnabled && !smtpInEnabled); setHidden(emailOutSection, !smtpOutEnabled); setHidden(emailInSection, !smtpInEnabled); // Show/hide configure buttons next to feature checkboxes modal.querySelectorAll(".cg-btn-configure").forEach((btn) => { const row = btn.closest(".cg-feature-row"); if (!row) return; const cb = row.querySelector('input[type="checkbox"]'); setHidden(btn, !(cb && cb.checked)); }); // If active nav tab got hidden, switch to General const activeNav = modal.querySelector(".cg-nav-tab.active"); if (activeNav && activeNav.classList.contains("cg-hidden")) { switchPanel(modal, "cg-panel-general"); } // Database tab — show only when PostgreSQL is selected and a DB-dependent feature is on setHidden(navDb, !(needsDb && isPostgres)); } function updatePostgresFields(modal, isPostgres) { // Show "Using PostgreSQL" instead of file inputs when PostgreSQL is selected ["auth-file", "web-push-file", "cache-file"].forEach((key) => { const input = modal.querySelector(`[data-key="${key}"]`); if (!input) return; const field = input.closest(".cg-field"); if (!field) return; input.style.display = isPostgres ? "none" : ""; if (isPostgres) { input.dataset.pgDisabled = "1"; } else { delete input.dataset.pgDisabled; } let pgLabel = field.querySelector(".cg-pg-label"); if (isPostgres) { if (!pgLabel) { pgLabel = document.createElement("span"); pgLabel.className = "cg-pg-label"; pgLabel.textContent = "Using PostgreSQL"; input.parentNode.insertBefore(pgLabel, input.nextSibling); } pgLabel.style.display = ""; } else if (pgLabel) { pgLabel.style.display = "none"; } }); // iOS question → upstream-base-url const iosYes = modal.querySelector('input[name="cg-ios"][value="yes"]'); const upstreamInput = modal.querySelector('[data-key="upstream-base-url"]'); if (iosYes && upstreamInput) { upstreamInput.value = iosYes.checked ? "https://ntfy.sh" : ""; } } function prefillDefaults(modal, flags) { const { isPostgres, authEnabled, cacheEnabled, attachEnabled, webpushEnabled, smtpOutEnabled, smtpInEnabled } = flags; if (isPostgres) { prefill(modal, "database-url", "postgres://user:pass@host:5432/ntfy"); } if (authEnabled) { if (!isPostgres) prefill(modal, "auth-file", "/var/lib/ntfy/auth.db"); } if (cacheEnabled) { if (!isPostgres) prefill(modal, "cache-file", "/var/cache/ntfy/cache.db"); } if (attachEnabled) { prefill(modal, "attachment-cache-dir", "/var/cache/ntfy/attachments"); } if (webpushEnabled) { if (!isPostgres) prefill(modal, "web-push-file", "/var/lib/ntfy/webpush.db"); prefill(modal, "web-push-email-address", "admin@example.com"); } if (smtpOutEnabled) { prefill(modal, "smtp-sender-addr", "smtp.example.com:587"); prefill(modal, "smtp-sender-from", "ntfy@example.com"); prefill(modal, "smtp-sender-user", "yoursmtpuser"); prefill(modal, "smtp-sender-pass", "yoursmtppass"); } if (smtpInEnabled) { prefill(modal, "smtp-server-listen", ":25"); prefill(modal, "smtp-server-domain", "ntfy.example.com"); } } function autoDetectServerType(els, loginModeVal) { const { modal, accessSelect } = els; const serverTypeRadio = modal.querySelector('input[name="cg-server-type"]:checked'); const serverType = serverTypeRadio ? serverTypeRadio.value : "open"; if (serverType !== "custom") { const currentAccess = accessSelect ? accessSelect.value : "read-write"; const currentLoginEnabled = loginModeVal !== "disabled"; const matchesOpen = currentAccess === "read-write" && !currentLoginEnabled; const matchesPrivate = currentAccess === "deny-all" && currentLoginEnabled; if (!matchesOpen && !matchesPrivate) { const customRadio = modal.querySelector('input[name="cg-server-type"][value="custom"]'); if (customRadio) customRadio.checked = true; } } } function updateVisibility(els) { const { modal, authCheckbox, cacheCheckbox, attachCheckbox, webpushCheckbox, smtpOutCheckbox, smtpInCheckbox } = els; const isPostgresRadio = modal.querySelector('input[name="cg-db-type"][value="postgres"]'); const isPostgres = isPostgresRadio && isPostgresRadio.checked; // Auto-enable auth when PostgreSQL is selected if (isPostgres && authCheckbox && !authCheckbox.checked) { authCheckbox.checked = true; } const authEnabled = authCheckbox && authCheckbox.checked; const cacheEnabled = cacheCheckbox && cacheCheckbox.checked; const attachEnabled = attachCheckbox && attachCheckbox.checked; const webpushEnabled = webpushCheckbox && webpushCheckbox.checked; const smtpOutEnabled = smtpOutCheckbox && smtpOutCheckbox.checked; const smtpInEnabled = smtpInCheckbox && smtpInCheckbox.checked; const needsDb = authEnabled || cacheEnabled || webpushEnabled; const flags = { isPostgres, authEnabled, cacheEnabled, attachEnabled, webpushEnabled, smtpOutEnabled, smtpInEnabled, needsDb }; const loginModeVal = syncRadiosToHiddenInputs(els); updateFeatureVisibility(els, flags); updatePostgresFields(modal, isPostgres); prefillDefaults(modal, flags); autoDetectServerType(els, loginModeVal); } // --- Repeatable rows --- function addRepeatableRow(container, type, onUpdate) { const row = document.createElement("div"); row.className = `cg-repeatable-row cg-auth-${type}-row`; if (type === "user") { row.innerHTML = '' + '' + '' + ''; } else if (type === "acl") { row.innerHTML = '' + '' + '' + ''; } else if (type === "token") { row.innerHTML = '' + `` + '' + ''; } row.querySelector(".cg-btn-remove").addEventListener("click", () => { row.remove(); onUpdate(); }); row.querySelectorAll("input, select").forEach((el) => { el.addEventListener("input", onUpdate); }); container.appendChild(row); } // --- Modal functions (module-level) --- function openModal(els) { els.modal.style.display = ""; document.body.style.overflow = "hidden"; updateVisibility(els); updateOutput(els); } function closeModal(els) { els.modal.style.display = "none"; document.body.style.overflow = ""; } function resetAll(els) { const { modal } = els; // Reset all text/password inputs and clear flags modal.querySelectorAll('input[type="text"], input[type="password"]').forEach((el) => { el.value = ""; delete el.dataset.cleared; }); // Uncheck all checkboxes modal.querySelectorAll('input[type="checkbox"]').forEach((el) => { el.checked = false; el.disabled = false; }); // Reset radio buttons to first option const radioGroups = {}; modal.querySelectorAll('input[type="radio"]').forEach((el) => { if (!radioGroups[el.name]) { radioGroups[el.name] = true; const first = modal.querySelector(`input[type="radio"][name="${el.name}"]`); if (first) first.checked = true; } else { el.checked = false; } }); // Reset selects to first option modal.querySelectorAll("select").forEach((el) => { el.selectedIndex = 0; }); // Remove all repeatable rows modal.querySelectorAll(".cg-auth-user-row, .cg-auth-acl-row, .cg-auth-token-row").forEach((row) => { row.remove(); }); // Re-prefill base-url const baseUrlInput = modal.querySelector('[data-key="base-url"]'); if (baseUrlInput) { const host = window.location.hostname; if (host && !host.includes("ntfy.sh")) { baseUrlInput.value = "https://ntfy.example.com"; } } // Reset to General tab switchPanel(modal, "cg-panel-general"); updateVisibility(els); updateOutput(els); } function fillVAPIDKeys(els) { const { modal } = els; generateVAPIDKeys().then((keys) => { const pubInput = modal.querySelector('[data-key="web-push-public-key"]'); const privInput = modal.querySelector('[data-key="web-push-private-key"]'); if (pubInput) pubInput.value = keys.publicKey; if (privInput) privInput.value = keys.privateKey; updateOutput(els); }); } // --- Event setup (grouped) --- function setupModalEvents(els) { const { modal } = els; const openBtn = document.getElementById("cg-open-btn"); const closeBtn = document.getElementById("cg-close-btn"); const backdrop = modal.querySelector(".cg-modal-backdrop"); const resetBtn = document.getElementById("cg-reset-btn"); if (openBtn) openBtn.addEventListener("click", () => openModal(els)); if (closeBtn) closeBtn.addEventListener("click", () => closeModal(els)); if (resetBtn) resetBtn.addEventListener("click", () => resetAll(els)); if (backdrop) backdrop.addEventListener("click", () => closeModal(els)); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && modal.style.display !== "none") { closeModal(els); } }); } function setupAuthEvents(els) { const { modal, authCheckbox, accessSelect } = els; if (!authCheckbox) return; // Auth checkbox: clean up when unchecked authCheckbox.addEventListener("change", () => { if (!authCheckbox.checked) { // Clear auth-file const authFile = modal.querySelector('[data-key="auth-file"]'); if (authFile) { authFile.value = ""; delete authFile.dataset.cleared; } // Reset default access if (accessSelect) accessSelect.value = "read-write"; // Reset login mode to Disabled and unset hidden checkboxes const loginDisabled = modal.querySelector('input[name="cg-login-mode"][value="disabled"]'); if (loginDisabled) loginDisabled.checked = true; if (els.loginHidden) els.loginHidden.checked = false; if (els.requireLoginHidden) els.requireLoginHidden.checked = false; const signupNo = modal.querySelector('input[name="cg-enable-signup"][value="no"]'); if (signupNo) signupNo.checked = true; if (els.signupHidden) els.signupHidden.checked = false; // Reset UnifiedPush to No const upNo = modal.querySelector('input[name="cg-unifiedpush"][value="no"]'); if (upNo) upNo.checked = true; // Remove provisioned users/ACLs/tokens modal.querySelectorAll(".cg-auth-user-row, .cg-auth-acl-row, .cg-auth-token-row").forEach((row) => { row.remove(); }); // Switch server type to Open const openRadio = modal.querySelector('input[name="cg-server-type"][value="open"]'); if (openRadio) openRadio.checked = true; } }); } function setupServerTypeEvents(els) { const { modal, authCheckbox, accessSelect } = els; modal.querySelectorAll('input[name="cg-server-type"]').forEach((radio) => { radio.addEventListener("change", () => { const loginDisabledRadio = modal.querySelector('input[name="cg-login-mode"][value="disabled"]'); const loginRequiredRadio = modal.querySelector('input[name="cg-login-mode"][value="required"]'); if (radio.value === "open") { if (accessSelect) accessSelect.value = "read-write"; if (loginDisabledRadio) loginDisabledRadio.checked = true; if (authCheckbox) authCheckbox.checked = false; // Trigger the auth cleanup authCheckbox.dispatchEvent(new Event("change")); } else if (radio.value === "private") { // Enable auth with required login if (authCheckbox) authCheckbox.checked = true; if (accessSelect) accessSelect.value = "deny-all"; if (loginRequiredRadio) loginRequiredRadio.checked = true; if (els.loginHidden) els.loginHidden.checked = true; if (els.requireLoginHidden) els.requireLoginHidden.checked = true; // Add default admin user if no users exist const usersContainer = modal.querySelector("#cg-auth-users-container"); if (usersContainer && !usersContainer.querySelector(".cg-auth-user-row")) { const onUpdate = () => { updateVisibility(els); updateOutput(els); }; addRepeatableRow(usersContainer, "user", onUpdate); const row = usersContainer.querySelector(".cg-auth-user-row:last-child"); if (row) { const u = row.querySelector('[data-field="username"]'); const p = row.querySelector('[data-field="password"]'); const r = row.querySelector('[data-field="role"]'); if (u) u.value = "ntfyadmin"; if (p) p.value = generatePassword(); if (r) r.value = "admin"; } } } // "custom" doesn't change anything }); }); } function setupFormListeners(els) { const { modal } = els; const onUpdate = () => { updateVisibility(els); updateOutput(els); }; // Left nav tab switching modal.querySelectorAll(".cg-nav-tab").forEach((tab) => { tab.addEventListener("click", () => { const panelId = tab.getAttribute("data-panel"); switchPanel(modal, panelId); }); }); // Configure buttons in feature grid modal.querySelectorAll(".cg-btn-configure").forEach((btn) => { btn.addEventListener("click", () => { const panelId = btn.getAttribute("data-panel"); if (panelId) switchPanel(modal, panelId); }); }); // Output format tab switching modal.querySelectorAll(".cg-output-tab").forEach((tab) => { tab.addEventListener("click", () => { modal.querySelectorAll(".cg-output-tab").forEach((t) => t.classList.remove("active")); tab.classList.add("active"); updateOutput(els); }); }); // All form inputs trigger update modal.querySelectorAll("input, select").forEach((el) => { const evt = (el.type === "checkbox" || el.type === "radio") ? "change" : "input"; el.addEventListener(evt, () => { // Mark text fields as cleared when user empties them if ((el.type === "text" || el.type === "password") && el.dataset.key && !el.value.trim()) { el.dataset.cleared = "1"; } else if ((el.type === "text" || el.type === "password") && el.dataset.key && el.value.trim()) { delete el.dataset.cleared; } onUpdate(); }); }); // Add buttons for repeatable rows modal.querySelectorAll(".cg-btn-add[data-add-type]").forEach((btn) => { btn.addEventListener("click", () => { const type = btn.getAttribute("data-add-type"); let container = btn.previousElementSibling; if (!container) container = btn.parentElement.querySelector(".cg-repeatable-container"); addRepeatableRow(container, type, onUpdate); }); }); // Copy button const copyBtn = modal.querySelector("#cg-copy-btn"); if (copyBtn) { const copyIcon = ''; const checkIcon = ''; copyBtn.addEventListener("click", () => { const code = modal.querySelector("#cg-code"); if (code && code.textContent) { navigator.clipboard.writeText(code.textContent).then(() => { copyBtn.innerHTML = checkIcon; copyBtn.style.color = "var(--md-primary-fg-color)"; setTimeout(() => { copyBtn.innerHTML = copyIcon; copyBtn.style.color = ""; }, 2000); }); } }); } } function setupWebPushEvents(els) { const { modal } = els; let vapidKeysGenerated = false; const regenBtn = modal.querySelector("#cg-regen-keys"); if (regenBtn) { regenBtn.addEventListener("click", () => fillVAPIDKeys(els)); } // Auto-generate keys when web push is first enabled const webpushFeat = modal.querySelector("#cg-feat-webpush"); if (webpushFeat) { webpushFeat.addEventListener("change", () => { if (webpushFeat.checked && !vapidKeysGenerated) { vapidKeysGenerated = true; fillVAPIDKeys(els); } }); } } // --- Init --- function initGenerator() { const modal = document.getElementById("cg-modal"); if (!modal) return; const els = cacheElements(modal); setupModalEvents(els); setupAuthEvents(els); setupServerTypeEvents(els); setupFormListeners(els); setupWebPushEvents(els); // Pre-fill base-url if not on ntfy.sh const baseUrlInput = modal.querySelector('[data-key="base-url"]'); if (baseUrlInput && !baseUrlInput.value.trim()) { const host = window.location.hostname; if (host && !host.includes("ntfy.sh")) { baseUrlInput.value = "https://ntfy.example.com"; } } // Auto-open if URL hash points to config generator if (window.location.hash === "#config-generator") { openModal(els); } } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initGenerator); } else { initGenerator(); } })();