From 2b36ad9eb9d9d2714b50139a6ab746b6eb161c70 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 8 Mar 2026 18:59:17 -0400 Subject: [PATCH 1/9] config generator --- docs/config-generator-plan.md | 164 +++++++++ docs/config.md | 239 +++++++++++++ docs/static/css/config-generator.css | 365 +++++++++++++++++++ docs/static/js/config-generator.js | 502 +++++++++++++++++++++++++++ mkdocs.yml | 2 + 5 files changed, 1272 insertions(+) create mode 100644 docs/config-generator-plan.md create mode 100644 docs/static/css/config-generator.css create mode 100644 docs/static/js/config-generator.js diff --git a/docs/config-generator-plan.md b/docs/config-generator-plan.md new file mode 100644 index 00000000..5fcd85e9 --- /dev/null +++ b/docs/config-generator-plan.md @@ -0,0 +1,164 @@ +# Config Generator for ntfy Docs + +## Context + +The ntfy config page (`docs/config.md`) documents 60+ server configuration options across many sections. Users often struggle to assemble a working config file. An interactive config generator helps users build a `server.yml`, `docker-compose.yml`, or env vars file by answering guided questions, with real-time preview. + +## Files Created + +| File | Purpose | +|------|---------| +| `docs/static/js/config-generator.js` | All generator logic (vanilla JS, ~300 lines) | +| `docs/static/css/config-generator.css` | All generator styles (~250 lines) | + +## Files Modified + +| File | Change | +|------|--------| +| `docs/config.md` | Inserted `## Config generator` HTML block at line 137 (before `## Database options`) | +| `mkdocs.yml` | Added `config-generator.js` to `extra_javascript` and `config-generator.css` to `extra_css` | + +## UI Layout + +Two-panel side-by-side layout: + +- **Left panel**: Collapsible accordion sections with form inputs for config options +- **Right panel** (sticky): Tabs for `server.yml` / `docker-compose.yml` / `Environment variables`, with a copy button. Updates in real-time as the user changes options. +- **Responsive**: Stacks vertically on screens < 900px + +## Configuration Sections (Left Panel) + +### 1. Basic Setup +- `base-url` (text, placeholder: `https://ntfy.example.com`) +- `listen-http` (text, placeholder: `:80`) +- `behind-proxy` (checkbox) + +### 2. Database +- Radio: SQLite (default) vs PostgreSQL +- SQLite → `cache-file` (text, placeholder: `/var/cache/ntfy/cache.db`) +- PostgreSQL → `database-url` (text, placeholder: `postgres://user:pass@host:5432/ntfy`) + +### 3. Access Control +- "Enable access control" (checkbox) → shows: + - `auth-file` (text, placeholder: `/var/lib/ntfy/auth.db`) — hidden if PostgreSQL + - `auth-default-access` (select: `read-write`, `read-only`, `write-only`, `deny-all`) + - `enable-login` (checkbox) + - `enable-signup` (checkbox) + - Provisioned users (repeatable rows): username, password hash, role (admin/user) + - Provisioned ACLs (repeatable rows): username, topic pattern, permission (rw/ro/wo/deny) + - Provisioned tokens (repeatable rows): username, token, label + +### 4. Attachments +- "Enable attachments" (checkbox) → shows: + - `attachment-cache-dir` (text, placeholder: `/var/cache/ntfy/attachments`) + - `attachment-file-size-limit` (text, placeholder: `15M`) + - `attachment-total-size-limit` (text, placeholder: `5G`) + - `attachment-expiry-duration` (text, placeholder: `3h`) + +### 5. Message Cache +- `cache-duration` (text, placeholder: `12h`) + +### 6. Web Push +- "Enable web push" (checkbox) → shows: + - `web-push-public-key` (text) + - `web-push-private-key` (text) + - `web-push-file` (text, placeholder: `/var/lib/ntfy/webpush.db`) — hidden if PostgreSQL + - `web-push-email-address` (text) + +### 7. Email Notifications (Outgoing) +- "Enable email sending" (checkbox) → shows: + - `smtp-sender-addr` (text, placeholder: `smtp.example.com:587`) + - `smtp-sender-from` (text, placeholder: `ntfy@example.com`) + - `smtp-sender-user` (text) + - `smtp-sender-pass` (text, type=password) + +### 8. Email Publishing (Incoming) +- "Enable email publishing" (checkbox) → shows: + - `smtp-server-listen` (text, placeholder: `:25`) + - `smtp-server-domain` (text, placeholder: `ntfy.example.com`) + - `smtp-server-addr-prefix` (text, placeholder: `ntfy-`) + +### 9. Upstream Server +- "iOS users will use this server" (checkbox) → sets `upstream-base-url: https://ntfy.sh` + +### 10. Monitoring +- "Enable Prometheus metrics" (checkbox) → sets `enable-metrics: true` + +## JavaScript Architecture (`config-generator.js`) + +Single vanilla JS file, no dependencies. Key structure: + +1. **Config definitions array (`CONFIG`)**: Each entry has `key`, `env`, `type`, `def`, `section`. This is the single source of truth for all three output formats. + +2. **`collectValues()`**: Reads all form inputs, returns an object of key→value, filtering out empty values. Handles repeatable rows for auth-users/access/tokens. Respects conditional visibility (skips hidden fields). + +3. **`generateServerYml(values)`**: Outputs YAML with section comments. Provisioned users/ACLs/tokens rendered as YAML arrays. + +4. **`generateDockerCompose(values)`**: Wraps values as env vars in Docker Compose format. Key transformations: + - Uses `NTFY_` prefixed env var names from config definitions + - File paths adjusted for Docker via `DOCKER_PATH_MAP` + - `$` doubled to `$$` in bcrypt hashes (with comment) + - Standard boilerplate: `services:`, `image:`, `volumes:`, `ports:`, `command: serve` + - Provisioned users/ACLs/tokens use indexed env vars (e.g., `NTFY_AUTH_USERS_0_USERNAME`) + +5. **`generateEnvVars(values)`**: Simple `export NTFY_KEY="value"` format. Single quotes for values containing `$`. + +6. **`updateOutput()`**: Called on every input change. Runs the active tab's generator and updates the `` element. + +7. **`initGenerator()`**: Called on DOMContentLoaded, no-ops if `#config-generator` is missing. Sets up event listeners for tabs, accordions, repeatable row add/remove, conditional toggles, and special checkboxes (upstream, metrics). + +### Conditional visibility +- Database = PostgreSQL → hide `cache-file`, `auth-file`, `web-push-file`; show `database-url` +- Access control unchecked → hide all auth fields +- Each "enable X" checkbox controls visibility of its section's detail fields via `data-toggle` attribute + +### Docker Compose path mapping +- `/var/cache/ntfy/cache.db` → `/var/lib/ntfy/cache.db` +- `/var/cache/ntfy/attachments` → `/var/lib/ntfy/attachments` +- `/var/lib/ntfy/*` → unchanged +- Volume: `./:/var/lib/ntfy` + +## CSS Architecture (`config-generator.css`) + +Key aspects: + +- **Flex layout** with `gap: 24px` for the two-panel layout +- **Sticky right panel**: `position: sticky; top: 76px; align-self: flex-start; max-height: calc(100vh - 100px); overflow-y: auto` +- **Dark mode**: Uses `body[data-md-color-scheme="slate"]` selectors (matching existing pattern in `extra.css`) +- **Accent color**: Uses `var(--md-primary-fg-color)` (`#338574`) for focus states, active tabs, and interactive elements +- **Responsive**: `@media (max-width: 900px)` → column layout, static right panel +- **Accordion**: `.cg-section-header` click toggles `.cg-section.open` which shows/hides `.cg-section-body` +- **Tabs**: `.cg-tab.active` gets accent color border-bottom highlight +- **Code output**: Dark background (`#1e1e1e`) with light text, consistent in both light and dark mode + +## HTML Block (in config.md) + +~250 lines of HTML inserted at line 137. Pure HTML (no markdown inside). Structure: + +``` +## Config generator +
+
+ +
+
+ +
+
+``` + +## Verification + +The docs build was verified with `mkdocs build` — completed successfully with no errors. The generated HTML at `server/docs/config/index.html` contains the config-generator elements. + +Manual verification checklist: +1. `cd ntfy && mkdocs serve` — open config page in browser +2. Verify all 10 sections expand/collapse +3. Fill in basic setup → check server.yml output updates +4. Switch tabs → verify docker-compose.yml and env vars render correctly +5. Toggle access control → verify auth fields show/hide +6. Add provisioned users/ACLs/tokens → verify repeatable rows work +7. Switch database to PostgreSQL → verify SQLite fields hide +8. Toggle dark mode → verify styles look correct +9. Resize to mobile width → verify column layout +10. Click copy button → verify clipboard content diff --git a/docs/config.md b/docs/config.md index a202cd95..d5939a14 100644 --- a/docs/config.md +++ b/docs/config.md @@ -135,6 +135,245 @@ using Docker Compose (i.e. `docker-compose.yml`): command: serve ``` +## Config generator + +Use this interactive tool to build your ntfy configuration. Select options below and copy the generated config. + +
+
+
+
Basic Setup
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
Database
+
+
+ + +
+
+
+ + +
+
+ +
+
+
+
Access Control
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+
+
+
Attachments
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
Message Cache
+
+
+ + +
+
+
+
+
Web Push
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
Email Notifications (Outgoing)
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
Email Publishing (Incoming)
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
Upstream Server
+
+
+ + +
+ +
+
+
+
Monitoring
+
+
+ + +
+ +
+
+
+
+
+
server.yml
+
docker-compose.yml
+
Environment variables
+
+
+ +
Configure options on the left to generate your config...
+
+
+
+ ## Database options ntfy uses a database for storing messages ([message cache](#message-cache)), users and [access control](#access-control), and [web push](#web-push) subscriptions. You can choose between **SQLite** and **PostgreSQL** as the database backend. diff --git a/docs/static/css/config-generator.css b/docs/static/css/config-generator.css new file mode 100644 index 00000000..2d0ff062 --- /dev/null +++ b/docs/static/css/config-generator.css @@ -0,0 +1,365 @@ +/* Config Generator */ + +#config-generator-app { + display: flex; + gap: 24px; + margin: 1em 0 2em; + font-size: 0.82rem; +} + +#cg-left { + flex: 1; + min-width: 0; +} + +#cg-right { + width: 420px; + flex-shrink: 0; + position: sticky; + top: 76px; + align-self: flex-start; + max-height: calc(100vh - 100px); + overflow-y: auto; + border: 1px solid #ddd; + border-radius: 8px; + background: #f8f9fa; +} + +/* Accordion sections */ +.cg-section { + border: 1px solid #ddd; + border-radius: 6px; + margin-bottom: 8px; + overflow: hidden; +} + +.cg-section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + cursor: pointer; + font-weight: 500; + font-size: 0.88rem; + background: #f5f5f5; + user-select: none; + transition: background 0.15s; +} + +.cg-section-header:hover { + background: #eee; +} + +.cg-section-header::after { + content: '\25B6'; + font-size: 0.65em; + transition: transform 0.2s; +} + +.cg-section.open .cg-section-header::after { + transform: rotate(90deg); +} + +.cg-section-body { + display: none; + padding: 12px 14px; + border-top: 1px solid #ddd; +} + +.cg-section.open .cg-section-body { + display: block; +} + +/* Form fields */ +.cg-field { + margin-bottom: 10px; +} + +.cg-field:last-child { + margin-bottom: 0; +} + +.cg-field label { + display: block; + font-weight: 500; + margin-bottom: 3px; + font-size: 0.8rem; + color: #555; +} + +.cg-field input[type="text"], +.cg-field input[type="password"], +.cg-field select { + width: 100%; + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 0.82rem; + font-family: inherit; + box-sizing: border-box; + background: #fff; +} + +.cg-field input[type="text"]:focus, +.cg-field input[type="password"]:focus, +.cg-field select:focus { + border-color: var(--md-primary-fg-color); + outline: none; + box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15); +} + +.cg-checkbox { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.cg-checkbox input[type="checkbox"] { + accent-color: var(--md-primary-fg-color); +} + +.cg-checkbox label { + font-weight: 500; + font-size: 0.82rem; + margin: 0; + cursor: pointer; +} + +.cg-radio-group { + display: flex; + gap: 16px; + margin-bottom: 8px; +} + +.cg-radio-group label { + display: flex; + align-items: center; + gap: 4px; + font-weight: 400; + cursor: pointer; +} + +.cg-radio-group input[type="radio"] { + accent-color: var(--md-primary-fg-color); +} + +.cg-conditional { + display: none; + margin-top: 8px; +} + +.cg-conditional.visible { + display: block; +} + +/* Repeatable rows */ +.cg-repeatable-row { + display: flex; + gap: 6px; + align-items: center; + margin-bottom: 6px; + flex-wrap: wrap; +} + +.cg-repeatable-row input, +.cg-repeatable-row select { + flex: 1; + min-width: 80px; + padding: 5px 6px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 0.78rem; + font-family: inherit; + box-sizing: border-box; +} + +.cg-repeatable-row input:focus, +.cg-repeatable-row select:focus { + border-color: var(--md-primary-fg-color); + outline: none; +} + +.cg-btn-remove { + background: none; + border: 1px solid #ccc; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + padding: 3px 7px; + color: #999; + line-height: 1; +} + +.cg-btn-remove:hover { + background: #fee; + border-color: #c66; + color: #c33; +} + +.cg-btn-add { + background: none; + border: 1px dashed #bbb; + border-radius: 4px; + cursor: pointer; + padding: 5px 10px; + font-size: 0.78rem; + color: #777; + margin-top: 2px; +} + +.cg-btn-add:hover { + border-color: var(--md-primary-fg-color); + color: var(--md-primary-fg-color); +} + +/* Tabs */ +.cg-tabs { + display: flex; + border-bottom: 2px solid #ddd; +} + +.cg-tab { + padding: 8px 14px; + cursor: pointer; + font-size: 0.78rem; + font-weight: 500; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + color: #777; + transition: color 0.15s, border-color 0.15s; + user-select: none; +} + +.cg-tab:hover { + color: #444; +} + +.cg-tab.active { + color: var(--md-primary-fg-color); + border-bottom-color: var(--md-primary-fg-color); +} + +/* Output panel */ +.cg-output-wrap { + position: relative; + padding: 12px; +} + +.cg-output-wrap pre { + margin: 0; + padding: 12px; + background: #1e1e1e; + color: #d4d4d4; + border-radius: 6px; + overflow-x: auto; + font-size: 0.76rem; + line-height: 1.5; + min-height: 120px; + white-space: pre; +} + +.cg-btn-copy { + position: absolute; + top: 18px; + right: 18px; + background: #444; + color: #ddd; + border: none; + border-radius: 4px; + padding: 4px 10px; + font-size: 0.72rem; + cursor: pointer; + opacity: 0.8; + transition: opacity 0.15s; +} + +.cg-btn-copy:hover { + opacity: 1; +} + +.cg-empty-msg { + color: #888; + font-style: italic; +} + +/* Dark mode */ +body[data-md-color-scheme="slate"] #cg-right { + background: #2e303e; + border-color: #444; +} + +body[data-md-color-scheme="slate"] .cg-section { + border-color: #444; +} + +body[data-md-color-scheme="slate"] .cg-section-header { + background: #2e303e; + color: #ccc; +} + +body[data-md-color-scheme="slate"] .cg-section-header:hover { + background: #363849; +} + +body[data-md-color-scheme="slate"] .cg-section-body { + border-top-color: #444; +} + +body[data-md-color-scheme="slate"] .cg-field label { + color: #aaa; +} + +body[data-md-color-scheme="slate"] .cg-field input[type="text"], +body[data-md-color-scheme="slate"] .cg-field input[type="password"], +body[data-md-color-scheme="slate"] .cg-field select { + background: #1e1e2e; + border-color: #555; + color: #ddd; +} + +body[data-md-color-scheme="slate"] .cg-repeatable-row input, +body[data-md-color-scheme="slate"] .cg-repeatable-row select { + background: #1e1e2e; + border-color: #555; + color: #ddd; +} + +body[data-md-color-scheme="slate"] .cg-btn-remove { + border-color: #555; + color: #888; +} + +body[data-md-color-scheme="slate"] .cg-btn-add { + border-color: #555; + color: #888; +} + +body[data-md-color-scheme="slate"] .cg-tabs { + border-bottom-color: #444; +} + +body[data-md-color-scheme="slate"] .cg-tab { + color: #888; +} + +body[data-md-color-scheme="slate"] .cg-tab:hover { + color: #bbb; +} + +body[data-md-color-scheme="slate"] .cg-output-wrap pre { + background: #161620; +} + +/* Responsive */ +@media (max-width: 900px) { + #config-generator-app { + flex-direction: column; + } + + #cg-right { + width: 100%; + position: static; + max-height: none; + } +} diff --git a/docs/static/js/config-generator.js b/docs/static/js/config-generator.js new file mode 100644 index 00000000..4dc1fa69 --- /dev/null +++ b/docs/static/js/config-generator.js @@ -0,0 +1,502 @@ +// Config Generator for ntfy +(function () { + "use strict"; + + var CONFIG = [ + { key: "base-url", env: "NTFY_BASE_URL", section: "basic" }, + { key: "listen-http", env: "NTFY_LISTEN_HTTP", section: "basic", def: ":80" }, + { key: "behind-proxy", env: "NTFY_BEHIND_PROXY", section: "basic", type: "bool" }, + { key: "cache-file", env: "NTFY_CACHE_FILE", section: "database", def: "/var/cache/ntfy/cache.db" }, + { key: "database-url", env: "NTFY_DATABASE_URL", section: "database" }, + { key: "auth-file", env: "NTFY_AUTH_FILE", section: "auth", def: "/var/lib/ntfy/auth.db" }, + { key: "auth-default-access", env: "NTFY_AUTH_DEFAULT_ACCESS", section: "auth" }, + { key: "enable-login", env: "NTFY_ENABLE_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", def: "/var/cache/ntfy/attachments" }, + { 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-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", def: "/var/lib/ntfy/webpush.db" }, + { 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", def: ":25" }, + { 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" }, + { key: "enable-metrics", env: "NTFY_ENABLE_METRICS", section: "metrics", type: "bool" }, + ]; + + var DOCKER_PATH_MAP = { + "/var/cache/ntfy/cache.db": "/var/lib/ntfy/cache.db", + "/var/cache/ntfy/attachments": "/var/lib/ntfy/attachments", + }; + + function collectValues() { + var values = {}; + var gen = document.getElementById("config-generator-app"); + if (!gen) return values; + + var isPostgres = gen.querySelector('input[name="cg-db-type"][value="postgres"]'); + isPostgres = isPostgres && isPostgres.checked; + + CONFIG.forEach(function (c) { + var el = gen.querySelector('[data-key="' + c.key + '"]'); + if (!el) return; + + // Skip hidden fields + var container = el.closest(".cg-conditional"); + if (container && !container.classList.contains("visible")) return; + var field = el.closest(".cg-field"); + if (field && field.style.display === "none") return; + + var val; + if (c.type === "bool") { + if (el.checked) val = "true"; + } else { + val = el.value.trim(); + if (!val) return; + } + if (val) values[c.key] = val; + }); + + // Provisioned users + var userRows = gen.querySelectorAll(".cg-auth-user-row"); + var users = []; + userRows.forEach(function (row) { + var u = row.querySelector('[data-field="username"]'); + var p = row.querySelector('[data-field="password"]'); + var r = row.querySelector('[data-field="role"]'); + if (u && p && u.value.trim() && p.value.trim()) { + users.push({ username: u.value.trim(), password: p.value.trim(), role: r ? r.value : "user" }); + } + }); + if (users.length) values["_auth-users"] = users; + + // Provisioned ACLs + var aclRows = gen.querySelectorAll(".cg-auth-acl-row"); + var acls = []; + aclRows.forEach(function (row) { + var u = row.querySelector('[data-field="username"]'); + var t = row.querySelector('[data-field="topic"]'); + var p = row.querySelector('[data-field="permission"]'); + if (u && t && t.value.trim()) { + acls.push({ user: u.value.trim(), topic: t.value.trim(), permission: p ? p.value : "read-write" }); + } + }); + if (acls.length) values["_auth-acls"] = acls; + + // Provisioned tokens + var tokenRows = gen.querySelectorAll(".cg-auth-token-row"); + var tokens = []; + tokenRows.forEach(function (row) { + var u = row.querySelector('[data-field="username"]'); + var t = row.querySelector('[data-field="token"]'); + var l = row.querySelector('[data-field="label"]'); + if (u && t && u.value.trim() && t.value.trim()) { + tokens.push({ user: u.value.trim(), token: t.value.trim(), label: l ? l.value.trim() : "" }); + } + }); + if (tokens.length) values["_auth-tokens"] = tokens; + + return values; + } + + function generateServerYml(values) { + var lines = []; + var sections = { + 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", + metrics: "# Monitoring", + }; + var lastSection = ""; + + CONFIG.forEach(function (c) { + if (!(c.key in values)) return; + if (c.section !== lastSection) { + if (lines.length) lines.push(""); + if (sections[c.section]) lines.push(sections[c.section]); + lastSection = c.section; + } + var val = values[c.key]; + if (c.type === "bool") { + lines.push(c.key + ": true"); + } else { + lines.push(c.key + ': "' + val + '"'); + } + }); + + // Auth users + if (values["_auth-users"]) { + if (lastSection !== "auth") { lines.push(""); lines.push("# Access control"); } + lines.push("auth-users:"); + values["_auth-users"].forEach(function (u) { + lines.push(" - username: " + u.username); + lines.push(" password: " + u.password); + lines.push(" role: " + u.role); + }); + } + + // Auth ACLs + if (values["_auth-acls"]) { + lines.push("auth-default-access:"); + lines.push(" everyone:"); + values["_auth-acls"].forEach(function (a) { + // This uses the topic-level provisioning format + }); + // Actually use provisioned format + lines.pop(); lines.pop(); + lines.push("auth-access:"); + values["_auth-acls"].forEach(function (a) { + lines.push(" - user: " + (a.user || "*")); + lines.push(" topic: " + a.topic); + lines.push(" permission: " + a.permission); + }); + } + + // Auth tokens + if (values["_auth-tokens"]) { + lines.push("auth-tokens:"); + values["_auth-tokens"].forEach(function (t) { + lines.push(" - user: " + t.user); + lines.push(" token: " + t.token); + if (t.label) lines.push(" label: " + t.label); + }); + } + + return lines.join("\n"); + } + + function dockerPath(p) { + return DOCKER_PATH_MAP[p] || p; + } + + function generateDockerCompose(values) { + var lines = []; + lines.push("services:"); + lines.push(" ntfy:"); + lines.push(' image: binwiederhier/ntfy'); + lines.push(" command: serve"); + lines.push(" environment:"); + + CONFIG.forEach(function (c) { + if (!(c.key in values)) return; + var val = values[c.key]; + if (c.type === "bool") { + val = "true"; + } else { + // Adjust paths for Docker + val = dockerPath(val); + } + // Double $ in bcrypt hashes + if (val.indexOf("$") !== -1) { + val = val.replace(/\$/g, "$$$$"); + lines.push(" # Note: $ is doubled to $$ for docker-compose"); + } + lines.push(" " + c.env + ": " + val); + }); + + // Auth users in Docker + if (values["_auth-users"]) { + lines.push(" # Note: $ is doubled to $$ for docker-compose"); + values["_auth-users"].forEach(function (u, i) { + var pw = u.password.replace(/\$/g, "$$$$"); + lines.push(" NTFY_AUTH_USERS_" + i + "_USERNAME: " + u.username); + lines.push(" NTFY_AUTH_USERS_" + i + "_PASSWORD: " + pw); + lines.push(" NTFY_AUTH_USERS_" + i + "_ROLE: " + u.role); + }); + } + + // Auth ACLs in Docker + if (values["_auth-acls"]) { + values["_auth-acls"].forEach(function (a, i) { + lines.push(" NTFY_AUTH_ACCESS_" + i + "_USER: " + (a.user || "*")); + lines.push(" NTFY_AUTH_ACCESS_" + i + "_TOPIC: " + a.topic); + lines.push(" NTFY_AUTH_ACCESS_" + i + "_PERMISSION: " + a.permission); + }); + } + + // Auth tokens in Docker + if (values["_auth-tokens"]) { + values["_auth-tokens"].forEach(function (t, i) { + var tok = t.token.replace(/\$/g, "$$$$"); + lines.push(" NTFY_AUTH_TOKENS_" + i + "_USER: " + t.user); + lines.push(" NTFY_AUTH_TOKENS_" + i + "_TOKEN: " + tok); + if (t.label) lines.push(" NTFY_AUTH_TOKENS_" + i + "_LABEL: " + t.label); + }); + } + + lines.push(" volumes:"); + lines.push(" - ./:/var/lib/ntfy"); + lines.push(" ports:"); + + var listen = values["listen-http"] || ":80"; + var port = listen.replace(/.*:/, ""); + lines.push(' - "8080:' + port + '"'); + + return lines.join("\n"); + } + + function generateEnvVars(values) { + var lines = []; + + CONFIG.forEach(function (c) { + if (!(c.key in values)) return; + var val = values[c.key]; + if (c.type === "bool") val = "true"; + // Use single quotes if value contains $ + var q = val.indexOf("$") !== -1 ? "'" : '"'; + lines.push("export " + c.env + "=" + q + val + q); + }); + + if (values["_auth-users"]) { + values["_auth-users"].forEach(function (u, i) { + var q = u.password.indexOf("$") !== -1 ? "'" : '"'; + lines.push("export NTFY_AUTH_USERS_" + i + '_USERNAME="' + u.username + '"'); + lines.push("export NTFY_AUTH_USERS_" + i + "_PASSWORD=" + q + u.password + q); + lines.push("export NTFY_AUTH_USERS_" + i + '_ROLE="' + u.role + '"'); + }); + } + + if (values["_auth-acls"]) { + values["_auth-acls"].forEach(function (a, i) { + lines.push("export NTFY_AUTH_ACCESS_" + i + '_USER="' + (a.user || "*") + '"'); + lines.push("export NTFY_AUTH_ACCESS_" + i + '_TOPIC="' + a.topic + '"'); + lines.push("export NTFY_AUTH_ACCESS_" + i + '_PERMISSION="' + a.permission + '"'); + }); + } + + if (values["_auth-tokens"]) { + values["_auth-tokens"].forEach(function (t, i) { + var q = t.token.indexOf("$") !== -1 ? "'" : '"'; + lines.push("export NTFY_AUTH_TOKENS_" + i + '_USER="' + t.user + '"'); + lines.push("export NTFY_AUTH_TOKENS_" + i + "_TOKEN=" + q + t.token + q); + if (t.label) lines.push("export NTFY_AUTH_TOKENS_" + i + '_LABEL="' + t.label + '"'); + }); + } + + return lines.join("\n"); + } + + function updateOutput() { + var gen = document.getElementById("config-generator-app"); + if (!gen) return; + + var values = collectValues(); + var codeEl = gen.querySelector("#cg-code"); + if (!codeEl) return; + + var activeTab = gen.querySelector(".cg-tab.active"); + var format = activeTab ? activeTab.getAttribute("data-format") : "server-yml"; + + var output = ""; + var hasValues = false; + for (var k in values) { + if (values.hasOwnProperty(k)) { hasValues = true; break; } + } + + if (!hasValues) { + codeEl.innerHTML = 'Configure options on the left to generate your config...'; + return; + } + + if (format === "server-yml") { + output = generateServerYml(values); + } else if (format === "docker-compose") { + output = generateDockerCompose(values); + } else { + output = generateEnvVars(values); + } + + codeEl.textContent = output; + } + + function updateConditionalVisibility() { + var gen = document.getElementById("config-generator-app"); + if (!gen) return; + + var isPostgres = gen.querySelector('input[name="cg-db-type"][value="postgres"]'); + isPostgres = isPostgres && isPostgres.checked; + + // Database fields + var sqliteFields = gen.querySelector("#cg-sqlite-fields"); + var pgFields = gen.querySelector("#cg-postgres-fields"); + if (sqliteFields) sqliteFields.style.display = isPostgres ? "none" : "block"; + if (pgFields) pgFields.style.display = isPostgres ? "block" : "none"; + + // Hide auth-file and web-push-file if PostgreSQL + var authFile = gen.querySelector('[data-key="auth-file"]'); + if (authFile) { + var authField = authFile.closest(".cg-field"); + if (authField) authField.style.display = isPostgres ? "none" : ""; + } + var wpFile = gen.querySelector('[data-key="web-push-file"]'); + if (wpFile) { + var wpField = wpFile.closest(".cg-field"); + if (wpField) wpField.style.display = isPostgres ? "none" : ""; + } + + // Conditional sections (checkboxes that show/hide detail fields) + var toggles = gen.querySelectorAll("[data-toggle]"); + toggles.forEach(function (toggle) { + var target = gen.querySelector("#" + toggle.getAttribute("data-toggle")); + if (target) { + if (toggle.checked) { + target.classList.add("visible"); + } else { + target.classList.remove("visible"); + } + } + }); + } + + function addRepeatableRow(container, type) { + var 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", function () { + row.remove(); + updateOutput(); + }); + row.querySelectorAll("input, select").forEach(function (el) { + el.addEventListener("input", updateOutput); + }); + + container.appendChild(row); + } + + function initGenerator() { + var gen = document.getElementById("config-generator-app"); + if (!gen) return; + + // Accordion toggle + gen.querySelectorAll(".cg-section-header").forEach(function (header) { + header.addEventListener("click", function () { + header.parentElement.classList.toggle("open"); + }); + }); + + // Open first section by default + var first = gen.querySelector(".cg-section"); + if (first) first.classList.add("open"); + + // Tab switching + gen.querySelectorAll(".cg-tab").forEach(function (tab) { + tab.addEventListener("click", function () { + gen.querySelectorAll(".cg-tab").forEach(function (t) { t.classList.remove("active"); }); + tab.classList.add("active"); + updateOutput(); + }); + }); + + // All form inputs trigger update + gen.querySelectorAll("input, select").forEach(function (el) { + var evt = (el.type === "checkbox" || el.type === "radio") ? "change" : "input"; + el.addEventListener(evt, function () { + updateConditionalVisibility(); + updateOutput(); + }); + }); + + // Conditional toggles + gen.querySelectorAll("[data-toggle]").forEach(function (toggle) { + toggle.addEventListener("change", function () { + updateConditionalVisibility(); + updateOutput(); + }); + }); + + // Database radio + gen.querySelectorAll('input[name="cg-db-type"]').forEach(function (r) { + r.addEventListener("change", function () { + updateConditionalVisibility(); + updateOutput(); + }); + }); + + // Add buttons for repeatable rows + gen.querySelectorAll(".cg-btn-add").forEach(function (btn) { + btn.addEventListener("click", function () { + var type = btn.getAttribute("data-add-type"); + var container = btn.previousElementSibling; + if (!container) container = btn.parentElement.querySelector(".cg-repeatable-container"); + addRepeatableRow(container, type); + }); + }); + + // Copy button + var copyBtn = gen.querySelector("#cg-copy-btn"); + if (copyBtn) { + copyBtn.addEventListener("click", function () { + var code = gen.querySelector("#cg-code"); + if (code && code.textContent) { + navigator.clipboard.writeText(code.textContent).then(function () { + copyBtn.textContent = "Copied!"; + setTimeout(function () { copyBtn.textContent = "Copy"; }, 2000); + }); + } + }); + } + + // Upstream checkbox special handling + var upstreamCheck = gen.querySelector("#cg-upstream-check"); + if (upstreamCheck) { + upstreamCheck.addEventListener("change", function () { + var input = gen.querySelector('[data-key="upstream-base-url"]'); + if (input) input.value = upstreamCheck.checked ? "https://ntfy.sh" : ""; + updateOutput(); + }); + } + + // Metrics checkbox special handling + var metricsCheck = gen.querySelector("#cg-metrics-check"); + if (metricsCheck) { + metricsCheck.addEventListener("change", function () { + var input = gen.querySelector('[data-key="enable-metrics"]'); + if (input) input.checked = metricsCheck.checked; + updateOutput(); + }); + } + + updateConditionalVisibility(); + updateOutput(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initGenerator); + } else { + initGenerator(); + } +})(); diff --git a/mkdocs.yml b/mkdocs.yml index f2a7afb7..022ba03e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,8 +42,10 @@ extra: link: https://github.com/binwiederhier extra_javascript: - static/js/extra.js + - static/js/config-generator.js extra_css: - static/css/extra.css + - static/css/config-generator.css markdown_extensions: - admonition From 612afb143544efd2e17694a984bc326a50d6553d Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 8 Mar 2026 19:32:54 -0400 Subject: [PATCH 2/9] Configurator --- docs/config.md | 155 ++++++--------- docs/static/css/config-generator.css | 222 +++++++++++++-------- docs/static/js/config-generator.js | 276 +++++++++++++++------------ 3 files changed, 352 insertions(+), 301 deletions(-) diff --git a/docs/config.md b/docs/config.md index d5939a14..2fba771d 100644 --- a/docs/config.md +++ b/docs/config.md @@ -141,52 +141,61 @@ Use this interactive tool to build your ntfy configuration. Select options below
-
-
Basic Setup
-
-
- - +
+
+ +
-
- - -
-
- - +
+ +
+
+
+ +
+ +
-
-
Database
-
+
+
+ +
+ + + + + + + +
+
+ +
+
+
Server
- - + +
- -
-
-
Access Control
-
-
- - -
-
+ + -
-
Attachments
-
-
- - -
-
+ -
-
-
-
Message Cache
-
-
- - -
-
-
-
-
Web Push
-
-
- - -
-
+ -
-
-
-
Email Notifications (Outgoing)
-
-
- - -
-
+ -
-
-
-
Email Publishing (Incoming)
-
-
- - -
-
+ -
-
-
-
Upstream Server
-
-
- - -
-
-
-
-
Monitoring
-
-
- - -
-
server.yml
docker-compose.yml
-
Environment variables
+
-
Configure options on the left to generate your config...
diff --git a/docs/static/css/config-generator.css b/docs/static/css/config-generator.css index 2d0ff062..88d541ab 100644 --- a/docs/static/css/config-generator.css +++ b/docs/static/css/config-generator.css @@ -4,7 +4,7 @@ display: flex; gap: 24px; margin: 1em 0 2em; - font-size: 0.82rem; + font-size: 0.75rem; } #cg-left { @@ -13,7 +13,8 @@ } #cg-right { - width: 420px; + flex: 1; + min-width: 0; flex-shrink: 0; position: sticky; top: 76px; @@ -25,49 +26,115 @@ background: #f8f9fa; } -/* Accordion sections */ -.cg-section { +/* Wizard questions */ +.cg-wizard { border: 1px solid #ddd; - border-radius: 6px; - margin-bottom: 8px; - overflow: hidden; + border-radius: 8px; + padding: 16px 18px; + margin-bottom: 16px; + background: #f8f9fa; } -.cg-section-header { +.cg-wizard-step { + margin-bottom: 16px; +} + +.cg-wizard-step:last-child { + margin-bottom: 0; +} + +.cg-wizard-label { + display: block; + font-weight: 600; + font-size: 0.78rem; + margin-bottom: 6px; + color: #333; +} + +.cg-wizard-input { + width: 100%; + padding: 7px 10px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 0.82rem; + font-family: inherit; + box-sizing: border-box; + background: #fff; +} + +.cg-wizard-input:focus { + border-color: var(--md-primary-fg-color); + outline: none; + box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15); +} + +.cg-wizard-toggle label { display: flex; align-items: center; - justify-content: space-between; - padding: 10px 14px; + gap: 6px; + font-size: 0.82rem; cursor: pointer; - font-weight: 500; - font-size: 0.88rem; - background: #f5f5f5; - user-select: none; - transition: background 0.15s; } -.cg-section-header:hover { - background: #eee; +.cg-wizard-toggle input[type="checkbox"] { + accent-color: var(--md-primary-fg-color); } -.cg-section-header::after { - content: '\25B6'; - font-size: 0.65em; - transition: transform 0.2s; +.cg-radio-group { + display: flex; + gap: 16px; + flex-wrap: wrap; } -.cg-section.open .cg-section-header::after { - transform: rotate(90deg); +.cg-radio-group label { + display: flex; + align-items: center; + gap: 4px; + font-weight: 400; + font-size: 0.82rem; + cursor: pointer; } -.cg-section-body { - display: none; +.cg-radio-group input[type="radio"] { + accent-color: var(--md-primary-fg-color); +} + +.cg-feature-grid { + display: flex; + flex-direction: column; + gap: 5px; +} + +.cg-feature-grid label { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; + cursor: pointer; +} + +.cg-feature-grid input[type="checkbox"] { + accent-color: var(--md-primary-fg-color); +} + +/* Detail sections */ +#cg-details { + display: flex; + flex-direction: column; + gap: 12px; +} + +.cg-detail-section { + border: 1px solid #ddd; + border-radius: 6px; padding: 12px 14px; - border-top: 1px solid #ddd; } -.cg-section.open .cg-section-body { - display: block; +.cg-detail-heading { + font-weight: 600; + font-size: 0.78rem; + margin-bottom: 10px; + color: var(--md-primary-fg-color); } /* Form fields */ @@ -126,33 +193,6 @@ cursor: pointer; } -.cg-radio-group { - display: flex; - gap: 16px; - margin-bottom: 8px; -} - -.cg-radio-group label { - display: flex; - align-items: center; - gap: 4px; - font-weight: 400; - cursor: pointer; -} - -.cg-radio-group input[type="radio"] { - accent-color: var(--md-primary-fg-color); -} - -.cg-conditional { - display: none; - margin-top: 8px; -} - -.cg-conditional.visible { - display: block; -} - /* Repeatable rows */ .cg-repeatable-row { display: flex; @@ -242,15 +282,15 @@ /* Output panel */ .cg-output-wrap { - position: relative; padding: 12px; } .cg-output-wrap pre { margin: 0; padding: 12px; - background: #1e1e1e; - color: #d4d4d4; + background: #f5f5f5; + color: #333; + border: 1px solid #ddd; border-radius: 6px; overflow-x: auto; font-size: 0.76rem; @@ -260,22 +300,23 @@ } .cg-btn-copy { - position: absolute; - top: 18px; - right: 18px; - background: #444; - color: #ddd; + margin-left: auto; + background: none; + color: #777; border: none; - border-radius: 4px; - padding: 4px 10px; - font-size: 0.72rem; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + padding: 8px 10px; cursor: pointer; - opacity: 0.8; - transition: opacity 0.15s; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s; } .cg-btn-copy:hover { - opacity: 1; + color: #333; } .cg-empty-msg { @@ -289,21 +330,33 @@ body[data-md-color-scheme="slate"] #cg-right { border-color: #444; } -body[data-md-color-scheme="slate"] .cg-section { +body[data-md-color-scheme="slate"] .cg-wizard { + background: #2e303e; border-color: #444; } -body[data-md-color-scheme="slate"] .cg-section-header { - background: #2e303e; +body[data-md-color-scheme="slate"] .cg-wizard-label { color: #ccc; } -body[data-md-color-scheme="slate"] .cg-section-header:hover { - background: #363849; +body[data-md-color-scheme="slate"] .cg-wizard-input { + background: #1e1e2e; + border-color: #555; + color: #ddd; } -body[data-md-color-scheme="slate"] .cg-section-body { - border-top-color: #444; +body[data-md-color-scheme="slate"] .cg-wizard-toggle label, +body[data-md-color-scheme="slate"] .cg-radio-group label, +body[data-md-color-scheme="slate"] .cg-feature-grid label { + color: #ccc; +} + +body[data-md-color-scheme="slate"] .cg-detail-section { + border-color: #444; +} + +body[data-md-color-scheme="slate"] .cg-detail-heading { + color: var(--md-primary-fg-color); } body[data-md-color-scheme="slate"] .cg-field label { @@ -348,7 +401,21 @@ body[data-md-color-scheme="slate"] .cg-tab:hover { } body[data-md-color-scheme="slate"] .cg-output-wrap pre { - background: #161620; + background: #1e1e2e; + color: #ddd; + border-color: #444; +} + +body[data-md-color-scheme="slate"] .cg-btn-copy { + color: #777; +} + +body[data-md-color-scheme="slate"] .cg-btn-copy:hover { + color: #bbb; +} + +body[data-md-color-scheme="slate"] .cg-checkbox label { + color: #ccc; } /* Responsive */ @@ -362,4 +429,5 @@ body[data-md-color-scheme="slate"] .cg-output-wrap pre { position: static; max-height: none; } + } diff --git a/docs/static/js/config-generator.js b/docs/static/js/config-generator.js index 4dc1fa69..87ef0ab2 100644 --- a/docs/static/js/config-generator.js +++ b/docs/static/js/config-generator.js @@ -6,7 +6,6 @@ { key: "base-url", env: "NTFY_BASE_URL", section: "basic" }, { key: "listen-http", env: "NTFY_LISTEN_HTTP", section: "basic", def: ":80" }, { key: "behind-proxy", env: "NTFY_BEHIND_PROXY", section: "basic", type: "bool" }, - { key: "cache-file", env: "NTFY_CACHE_FILE", section: "database", def: "/var/cache/ntfy/cache.db" }, { key: "database-url", env: "NTFY_DATABASE_URL", section: "database" }, { key: "auth-file", env: "NTFY_AUTH_FILE", section: "auth", def: "/var/lib/ntfy/auth.db" }, { key: "auth-default-access", env: "NTFY_AUTH_DEFAULT_ACCESS", section: "auth" }, @@ -16,6 +15,7 @@ { 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", def: "/var/cache/ntfy/cache.db" }, { 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" }, @@ -37,21 +37,29 @@ "/var/cache/ntfy/attachments": "/var/lib/ntfy/attachments", }; + // Feature checkbox ID → detail section ID + var FEATURE_MAP = { + "cg-feat-cache": "cg-detail-cache", + "cg-feat-attach": "cg-detail-attach", + "cg-feat-webpush": "cg-detail-webpush", + "cg-feat-smtp-out": "cg-detail-smtp-out", + "cg-feat-smtp-in": "cg-detail-smtp-in", + }; + function collectValues() { var values = {}; var gen = document.getElementById("config-generator-app"); if (!gen) return values; - var isPostgres = gen.querySelector('input[name="cg-db-type"][value="postgres"]'); - isPostgres = isPostgres && isPostgres.checked; - CONFIG.forEach(function (c) { var el = gen.querySelector('[data-key="' + c.key + '"]'); if (!el) return; - // Skip hidden fields - var container = el.closest(".cg-conditional"); - if (container && !container.classList.contains("visible")) return; + // Skip fields in hidden detail sections + var section = el.closest(".cg-detail-section"); + if (section && section.style.display === "none") return; + + // Skip hidden individual fields (e.g. auth-file when using PostgreSQL) var field = el.closest(".cg-field"); if (field && field.style.display === "none") return; @@ -151,13 +159,6 @@ // Auth ACLs if (values["_auth-acls"]) { - lines.push("auth-default-access:"); - lines.push(" everyone:"); - values["_auth-acls"].forEach(function (a) { - // This uses the topic-level provisioning format - }); - // Actually use provisioned format - lines.pop(); lines.pop(); lines.push("auth-access:"); values["_auth-acls"].forEach(function (a) { lines.push(" - user: " + (a.user || "*")); @@ -249,47 +250,6 @@ return lines.join("\n"); } - function generateEnvVars(values) { - var lines = []; - - CONFIG.forEach(function (c) { - if (!(c.key in values)) return; - var val = values[c.key]; - if (c.type === "bool") val = "true"; - // Use single quotes if value contains $ - var q = val.indexOf("$") !== -1 ? "'" : '"'; - lines.push("export " + c.env + "=" + q + val + q); - }); - - if (values["_auth-users"]) { - values["_auth-users"].forEach(function (u, i) { - var q = u.password.indexOf("$") !== -1 ? "'" : '"'; - lines.push("export NTFY_AUTH_USERS_" + i + '_USERNAME="' + u.username + '"'); - lines.push("export NTFY_AUTH_USERS_" + i + "_PASSWORD=" + q + u.password + q); - lines.push("export NTFY_AUTH_USERS_" + i + '_ROLE="' + u.role + '"'); - }); - } - - if (values["_auth-acls"]) { - values["_auth-acls"].forEach(function (a, i) { - lines.push("export NTFY_AUTH_ACCESS_" + i + '_USER="' + (a.user || "*") + '"'); - lines.push("export NTFY_AUTH_ACCESS_" + i + '_TOPIC="' + a.topic + '"'); - lines.push("export NTFY_AUTH_ACCESS_" + i + '_PERMISSION="' + a.permission + '"'); - }); - } - - if (values["_auth-tokens"]) { - values["_auth-tokens"].forEach(function (t, i) { - var q = t.token.indexOf("$") !== -1 ? "'" : '"'; - lines.push("export NTFY_AUTH_TOKENS_" + i + '_USER="' + t.user + '"'); - lines.push("export NTFY_AUTH_TOKENS_" + i + "_TOKEN=" + q + t.token + q); - if (t.label) lines.push("export NTFY_AUTH_TOKENS_" + i + '_LABEL="' + t.label + '"'); - }); - } - - return lines.join("\n"); - } - function updateOutput() { var gen = document.getElementById("config-generator-app"); if (!gen) return; @@ -301,7 +261,6 @@ var activeTab = gen.querySelector(".cg-tab.active"); var format = activeTab ? activeTab.getAttribute("data-format") : "server-yml"; - var output = ""; var hasValues = false; for (var k in values) { if (values.hasOwnProperty(k)) { hasValues = true; break; } @@ -312,29 +271,69 @@ return; } - if (format === "server-yml") { - output = generateServerYml(values); - } else if (format === "docker-compose") { + var output = ""; + if (format === "docker-compose") { output = generateDockerCompose(values); } else { - output = generateEnvVars(values); + output = generateServerYml(values); } codeEl.textContent = output; } - function updateConditionalVisibility() { + // Set a field's value only if it is currently empty + function prefill(gen, key, value) { + var el = gen.querySelector('[data-key="' + key + '"]'); + if (el && !el.value.trim()) el.value = value; + } + + // Set a select's value (always, to reflect wizard state) + function prefillSelect(gen, key, value) { + var el = gen.querySelector('[data-key="' + key + '"]'); + if (el) el.value = value; + } + + function updateVisibility() { var gen = document.getElementById("config-generator-app"); if (!gen) return; var isPostgres = gen.querySelector('input[name="cg-db-type"][value="postgres"]'); isPostgres = isPostgres && isPostgres.checked; - // Database fields - var sqliteFields = gen.querySelector("#cg-sqlite-fields"); - var pgFields = gen.querySelector("#cg-postgres-fields"); - if (sqliteFields) sqliteFields.style.display = isPostgres ? "none" : "block"; - if (pgFields) pgFields.style.display = isPostgres ? "block" : "none"; + var isPrivate = gen.querySelector('input[name="cg-server-type"][value="private"]'); + isPrivate = isPrivate && isPrivate.checked; + + var cacheEnabled = gen.querySelector("#cg-feat-cache"); + cacheEnabled = cacheEnabled && cacheEnabled.checked; + + var attachEnabled = gen.querySelector("#cg-feat-attach"); + attachEnabled = attachEnabled && attachEnabled.checked; + + var webpushEnabled = gen.querySelector("#cg-feat-webpush"); + webpushEnabled = webpushEnabled && webpushEnabled.checked; + + var smtpOutEnabled = gen.querySelector("#cg-feat-smtp-out"); + smtpOutEnabled = smtpOutEnabled && smtpOutEnabled.checked; + + var smtpInEnabled = gen.querySelector("#cg-feat-smtp-in"); + smtpInEnabled = smtpInEnabled && smtpInEnabled.checked; + + // Show database question only if a DB-dependent feature is selected + var needsDb = isPrivate || cacheEnabled || webpushEnabled; + var dbStep = gen.querySelector("#cg-wizard-db"); + if (dbStep) dbStep.style.display = needsDb ? "" : "none"; + + // Database detail section (PostgreSQL only; SQLite needs no extra config) + var pgSection = gen.querySelector("#cg-detail-db-postgres"); + if (pgSection) pgSection.style.display = (needsDb && isPostgres) ? "" : "none"; + + // Hide cache-file in message cache section when PostgreSQL + var cacheFileField = gen.querySelector("#cg-cache-file-field"); + if (cacheFileField) cacheFileField.style.display = isPostgres ? "none" : ""; + + // Auth detail section + var authSection = gen.querySelector("#cg-detail-auth"); + if (authSection) authSection.style.display = isPrivate ? "" : "none"; // Hide auth-file and web-push-file if PostgreSQL var authFile = gen.querySelector('[data-key="auth-file"]'); @@ -348,18 +347,75 @@ if (wpField) wpField.style.display = isPostgres ? "none" : ""; } - // Conditional sections (checkboxes that show/hide detail fields) - var toggles = gen.querySelectorAll("[data-toggle]"); - toggles.forEach(function (toggle) { - var target = gen.querySelector("#" + toggle.getAttribute("data-toggle")); - if (target) { - if (toggle.checked) { - target.classList.add("visible"); - } else { - target.classList.remove("visible"); - } + // Feature toggles → detail sections + for (var featId in FEATURE_MAP) { + var checkbox = gen.querySelector("#" + featId); + var section = gen.querySelector("#" + FEATURE_MAP[featId]); + if (checkbox && section) { + section.style.display = checkbox.checked ? "" : "none"; } - }); + } + + // Upstream special handling + var upstreamCheck = gen.querySelector("#cg-feat-upstream"); + var upstreamInput = gen.querySelector('[data-key="upstream-base-url"]'); + if (upstreamCheck && upstreamInput) { + upstreamInput.value = upstreamCheck.checked ? "https://ntfy.sh" : ""; + } + + // Metrics special handling + var metricsCheck = gen.querySelector("#cg-feat-metrics"); + var metricsInput = gen.querySelector('[data-key="enable-metrics"]'); + if (metricsCheck && metricsInput) { + metricsInput.checked = metricsCheck.checked; + } + + // --- Pre-fill defaults based on wizard selections --- + + // Database + if (isPostgres) { + prefill(gen, "database-url", "postgres://user:pass@host:5432/ntfy"); + } + + // Access control: always sync default-access with open/private + if (isPrivate) { + prefillSelect(gen, "auth-default-access", "deny-all"); + if (!isPostgres) prefill(gen, "auth-file", "/var/lib/ntfy/auth.db"); + } else { + prefillSelect(gen, "auth-default-access", "read-write"); + } + + // Persistent message cache + if (cacheEnabled) { + if (!isPostgres) prefill(gen, "cache-file", "/var/cache/ntfy/cache.db"); + prefill(gen, "cache-duration", "12h"); + } + + // Attachments + if (attachEnabled) { + prefill(gen, "attachment-cache-dir", "/var/cache/ntfy/attachments"); + prefill(gen, "attachment-file-size-limit", "15M"); + prefill(gen, "attachment-total-size-limit", "5G"); + prefill(gen, "attachment-expiry-duration", "3h"); + } + + // Web push + if (webpushEnabled) { + if (!isPostgres) prefill(gen, "web-push-file", "/var/lib/ntfy/webpush.db"); + prefill(gen, "web-push-email-address", "admin@example.com"); + } + + // Email notifications (outgoing) + if (smtpOutEnabled) { + prefill(gen, "smtp-sender-addr", "smtp.example.com:587"); + prefill(gen, "smtp-sender-from", "ntfy@example.com"); + } + + // Email publishing (incoming) + if (smtpInEnabled) { + prefill(gen, "smtp-server-listen", ":25"); + prefill(gen, "smtp-server-domain", "ntfy.example.com"); + } } function addRepeatableRow(container, type) { @@ -401,17 +457,6 @@ var gen = document.getElementById("config-generator-app"); if (!gen) return; - // Accordion toggle - gen.querySelectorAll(".cg-section-header").forEach(function (header) { - header.addEventListener("click", function () { - header.parentElement.classList.toggle("open"); - }); - }); - - // Open first section by default - var first = gen.querySelector(".cg-section"); - if (first) first.classList.add("open"); - // Tab switching gen.querySelectorAll(".cg-tab").forEach(function (tab) { tab.addEventListener("click", function () { @@ -425,23 +470,7 @@ gen.querySelectorAll("input, select").forEach(function (el) { var evt = (el.type === "checkbox" || el.type === "radio") ? "change" : "input"; el.addEventListener(evt, function () { - updateConditionalVisibility(); - updateOutput(); - }); - }); - - // Conditional toggles - gen.querySelectorAll("[data-toggle]").forEach(function (toggle) { - toggle.addEventListener("change", function () { - updateConditionalVisibility(); - updateOutput(); - }); - }); - - // Database radio - gen.querySelectorAll('input[name="cg-db-type"]').forEach(function (r) { - r.addEventListener("change", function () { - updateConditionalVisibility(); + updateVisibility(); updateOutput(); }); }); @@ -459,38 +488,33 @@ // Copy button var copyBtn = gen.querySelector("#cg-copy-btn"); if (copyBtn) { + var copyIcon = ''; + var checkIcon = ''; copyBtn.addEventListener("click", function () { var code = gen.querySelector("#cg-code"); if (code && code.textContent) { navigator.clipboard.writeText(code.textContent).then(function () { - copyBtn.textContent = "Copied!"; - setTimeout(function () { copyBtn.textContent = "Copy"; }, 2000); + copyBtn.innerHTML = checkIcon; + copyBtn.style.color = "var(--md-primary-fg-color)"; + setTimeout(function () { + copyBtn.innerHTML = copyIcon; + copyBtn.style.color = ""; + }, 2000); }); } }); } - // Upstream checkbox special handling - var upstreamCheck = gen.querySelector("#cg-upstream-check"); - if (upstreamCheck) { - upstreamCheck.addEventListener("change", function () { - var input = gen.querySelector('[data-key="upstream-base-url"]'); - if (input) input.value = upstreamCheck.checked ? "https://ntfy.sh" : ""; - updateOutput(); - }); + // Pre-fill base-url if not on ntfy.sh + var baseUrlInput = gen.querySelector('[data-key="base-url"]'); + if (baseUrlInput && !baseUrlInput.value.trim()) { + var host = window.location.hostname; + if (host && host.indexOf("ntfy.sh") === -1) { + baseUrlInput.value = "https://ntfy.example.com"; + } } - // Metrics checkbox special handling - var metricsCheck = gen.querySelector("#cg-metrics-check"); - if (metricsCheck) { - metricsCheck.addEventListener("change", function () { - var input = gen.querySelector('[data-key="enable-metrics"]'); - if (input) input.checked = metricsCheck.checked; - updateOutput(); - }); - } - - updateConditionalVisibility(); + updateVisibility(); updateOutput(); } From 19d1618bb8ae2712d4223a547e5fe7d7213f1fb5 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 8 Mar 2026 22:00:08 -0400 Subject: [PATCH 3/9] Continued --- docs/config.md | 132 ++++--- docs/static/css/config-generator.css | 563 +++++++++++++++------------ docs/static/js/config-generator.js | 541 ++++++++++++++++++------- 3 files changed, 798 insertions(+), 438 deletions(-) diff --git a/docs/config.md b/docs/config.md index 2fba771d..50d45c71 100644 --- a/docs/config.md +++ b/docs/config.md @@ -137,65 +137,83 @@ using Docker Compose (i.e. `docker-compose.yml`): ## Config generator -Use this interactive tool to build your ntfy configuration. Select options below and copy the generated config. - -
+ +