mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-03-18 13:20:48 +01:00
1310 lines
50 KiB
JavaScript
1310 lines
50 KiB
JavaScript
// Config Generator for ntfy
|
|
//
|
|
// Warning, AI code
|
|
// ----------------
|
|
// This code is entirely AI generated, but this very comment is not. Phil wrote this. Hi!
|
|
// I felt like the Config Generator was a great feature to have, but it would have taken me forever
|
|
// to write this code without AI. I reviewed the code manually, and it doesn't do anything dangerous.
|
|
// It's not the greatest code, but it works well enough to deliver value, and that's what it's all about.
|
|
//
|
|
// End of human comment. ;)
|
|
//
|
|
// 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, setupUnifiedPushEvents, 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, enables auth (if not already on) and adds a disabled "*:up*:write-only"
|
|
// ACL row to the Users tab. The row's fields are grayed out and non-editable. It is
|
|
// collected like any other ACL row. Clicking its [x] removes the row and toggles
|
|
// UnifiedPush back to No.
|
|
//
|
|
// 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;
|
|
|
|
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 ---
|
|
|
|
const bcryptCache = {};
|
|
|
|
function hashPassword(username, password) {
|
|
if (password.startsWith("$2")) return password; // already a bcrypt hash
|
|
const cacheKey = username + "\0" + password;
|
|
if (bcryptCache[cacheKey]) return bcryptCache[cacheKey];
|
|
const hash = (typeof bcrypt !== "undefined") ? bcrypt.hashSync(password, 10) : password;
|
|
bcryptCache[cacheKey] = hash;
|
|
return hash;
|
|
}
|
|
|
|
function formatAuthUsers(values) {
|
|
if (!values["_auth-users"]) return null;
|
|
return values["_auth-users"].map((u) => `${u.username}:${hashPassword(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 insertion point for auth-users/auth-access/auth-tokens:
|
|
// right after the last "auth-" prefixed line, before enable-*/require-* lines
|
|
let authInsertIdx = lines.length;
|
|
if (hadAuth) {
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (lines[i] === "# Access control") {
|
|
// Find the last auth-* prefixed key in this section
|
|
let lastAuthKey = i;
|
|
for (let j = i + 1; j < lines.length; j++) {
|
|
if (lines[j].startsWith("# ")) break;
|
|
if (lines[j].startsWith("auth-")) lastAuthKey = j;
|
|
}
|
|
authInsertIdx = lastAuthKey + 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:"
|
|
];
|
|
|
|
let hasDollarNote = false;
|
|
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, "$$$$");
|
|
hasDollarNote = true;
|
|
}
|
|
lines.push(` ${c.env}: "${val}"`);
|
|
});
|
|
|
|
const users = formatAuthUsers(values);
|
|
if (users) {
|
|
let usersVal = users.join(",");
|
|
usersVal = usersVal.replace(/\$/g, "$$$$");
|
|
hasDollarNote = true;
|
|
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(",")}"`);
|
|
}
|
|
|
|
if (hasDollarNote) {
|
|
// Insert note after "environment:" line
|
|
const envIdx = lines.indexOf(" environment:");
|
|
if (envIdx !== -1) {
|
|
lines.splice(envIdx + 1, 0, " # Note: $ is doubled to $$ for docker-compose");
|
|
}
|
|
}
|
|
|
|
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 = "<span class=\"cg-empty-msg\">Configure options on the left to generate your config...</span>";
|
|
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) => `<div class="cg-warning">${w}</div>`).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") {
|
|
const username = `newuser${Math.floor(Math.random() * 100) + 1}`;
|
|
row.innerHTML =
|
|
`<input type="text" data-field="username" placeholder="Username" value="${username}">` +
|
|
`<input type="text" data-field="password" placeholder="Password" value="${generatePassword()}">` +
|
|
"<select data-field=\"role\"><option value=\"user\">User</option><option value=\"admin\">Admin</option></select>" +
|
|
"<button type=\"button\" class=\"cg-btn-remove\" title=\"Remove\">×</button>";
|
|
} else if (type === "acl") {
|
|
let aclUser = `someuser${Math.floor(Math.random() * 100) + 1}`;
|
|
const modal = container.closest(".cg-modal");
|
|
if (modal) {
|
|
const userRows = modal.querySelectorAll(".cg-auth-user-row");
|
|
for (const ur of userRows) {
|
|
const role = ur.querySelector("[data-field=\"role\"]");
|
|
const name = ur.querySelector("[data-field=\"username\"]");
|
|
if (role && role.value !== "admin" && name && name.value.trim()) {
|
|
aclUser = name.value.trim();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
row.innerHTML =
|
|
`<input type="text" data-field="username" placeholder="Username (* for everyone)" value="${aclUser}">` +
|
|
"<input type=\"text\" data-field=\"topic\" placeholder=\"Topic pattern\" value=\"sometopic*\">" +
|
|
"<select data-field=\"permission\"><option value=\"read-write\">Read & Write</option><option value=\"read-only\">Read Only</option><option value=\"write-only\">Write Only</option><option value=\"deny\">Deny</option></select>" +
|
|
"<button type=\"button\" class=\"cg-btn-remove\" title=\"Remove\">×</button>";
|
|
} else if (type === "token") {
|
|
let tokenUser = "";
|
|
const modal = container.closest(".cg-modal");
|
|
if (modal) {
|
|
const firstRow = modal.querySelector(".cg-auth-user-row");
|
|
const name = firstRow ? firstRow.querySelector("[data-field=\"username\"]") : null;
|
|
if (name && name.value.trim()) tokenUser = name.value.trim();
|
|
}
|
|
row.innerHTML =
|
|
`<input type="text" data-field="username" placeholder="Username" value="${tokenUser}">` +
|
|
`<input type="text" data-field="token" placeholder="Token" value="${generateToken()}">` +
|
|
"<input type=\"text\" data-field=\"label\" placeholder=\"Label (optional)\">" +
|
|
"<button type=\"button\" class=\"cg-btn-remove\" title=\"Remove\">×</button>";
|
|
}
|
|
|
|
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 adminRow = usersContainer.querySelector(".cg-auth-user-row:last-child");
|
|
if (adminRow) {
|
|
const u = adminRow.querySelector("[data-field=\"username\"]");
|
|
const p = adminRow.querySelector("[data-field=\"password\"]");
|
|
const r = adminRow.querySelector("[data-field=\"role\"]");
|
|
if (u) u.value = "ntfyadmin";
|
|
if (p) p.value = generatePassword();
|
|
if (r) r.value = "admin";
|
|
}
|
|
addRepeatableRow(usersContainer, "user", onUpdate);
|
|
const userRow = usersContainer.querySelector(".cg-auth-user-row:last-child");
|
|
if (userRow) {
|
|
const u = userRow.querySelector("[data-field=\"username\"]");
|
|
const p = userRow.querySelector("[data-field=\"password\"]");
|
|
if (u) u.value = "ntfyuser";
|
|
if (p) p.value = generatePassword();
|
|
}
|
|
}
|
|
}
|
|
// "custom" doesn't change anything
|
|
});
|
|
});
|
|
}
|
|
|
|
function setupUnifiedPushEvents(els) {
|
|
const { modal } = els;
|
|
const onUpdate = () => {
|
|
updateVisibility(els);
|
|
updateOutput(els);
|
|
};
|
|
|
|
modal.querySelectorAll("input[name=\"cg-unifiedpush\"]").forEach((radio) => {
|
|
radio.addEventListener("change", () => {
|
|
const aclsContainer = modal.querySelector("#cg-auth-acls-container");
|
|
if (!aclsContainer) return;
|
|
const existing = aclsContainer.querySelector(".cg-auth-acl-row-up");
|
|
if (radio.value === "yes" && radio.checked && !existing) {
|
|
// Enable auth if not already enabled
|
|
if (els.authCheckbox && !els.authCheckbox.checked) {
|
|
els.authCheckbox.checked = true;
|
|
}
|
|
// Add a disabled UnifiedPush ACL row
|
|
const row = document.createElement("div");
|
|
row.className = "cg-repeatable-row cg-auth-acl-row cg-auth-acl-row-up";
|
|
row.innerHTML =
|
|
"<input type=\"text\" data-field=\"username\" value=\"*\" disabled>" +
|
|
"<input type=\"text\" data-field=\"topic\" value=\"up*\" disabled>" +
|
|
"<select data-field=\"permission\" disabled><option value=\"write-only\">Write Only</option></select>" +
|
|
"<button type=\"button\" class=\"cg-btn-remove\" title=\"Removing this ACL entry will disable UnifiedPush support\">×</button>";
|
|
row.querySelector(".cg-btn-remove").addEventListener("click", () => {
|
|
row.remove();
|
|
const upNo = modal.querySelector("input[name=\"cg-unifiedpush\"][value=\"no\"]");
|
|
if (upNo) upNo.checked = true;
|
|
onUpdate();
|
|
});
|
|
// Insert at the beginning
|
|
aclsContainer.insertBefore(row, aclsContainer.firstChild);
|
|
onUpdate();
|
|
} else if (radio.value === "no" && radio.checked && existing) {
|
|
existing.remove();
|
|
onUpdate();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
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);
|
|
onUpdate();
|
|
});
|
|
});
|
|
|
|
// Copy button
|
|
const copyBtn = modal.querySelector("#cg-copy-btn");
|
|
if (copyBtn) {
|
|
const copyIcon = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"></rect><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"></path></svg>";
|
|
const checkIcon = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"></polyline></svg>";
|
|
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);
|
|
setupUnifiedPushEvents(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();
|
|
}
|
|
})();
|