diff --git a/docs/publish.md b/docs/publish.md index 9ac600e9..0060cdcc 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -1,7 +1,7 @@ # Publishing Publishing messages can be done via HTTP PUT/POST or via the [ntfy CLI](subscribe/cli.md#publish-messages) ([install instructions](install.md)). Topics are created on the fly by subscribing or publishing to them. Because there is no sign-up, **the topic is essentially a password**, so pick -something that's not easily guessable. +something that's not easily guessable (see [picking a topic](#picking-a-topic) for a handy topic name generator). Here's an example showing how to publish a simple message using a POST request: @@ -308,6 +308,44 @@ an [external image attachment](#attach-file-from-a-url) and [email publishing](#
Notification using a click action, a user action, with an external image attachment and forwarded via email
+## Picking a topic +Since there is no sign-up, **the topic is essentially a password**, so pick something that's not easily guessable. Topic names may +only contain letters, numbers, underscores and dashes (`[-_A-Za-z0-9]`), and may be up to 64 characters long. + +Not sure what to pick? Type a name below and the generator will add a random, hard-to-guess suffix for you. Everything happens locally in your browser: + +
+
+Topic name generator + +
+
+
+
+ + +
+
Spaces and characters other than letters, numbers, - and _ are removed automatically as you type. Names are capped at 64 characters.
+
+
+
+Your topic: +
+

+
+
+
+
+Your topic URL: +
+
https://ntfy.sh/
+ +
+
+
+
+
+ ## Message title _Supported on:_ :material-android: :material-apple: :material-firefox: diff --git a/docs/static/css/topic-generator.css b/docs/static/css/topic-generator.css new file mode 100644 index 00000000..5770da22 --- /dev/null +++ b/docs/static/css/topic-generator.css @@ -0,0 +1,235 @@ +/* Topic name generator (Publishing page) */ +/* Styled to mirror the config generator (header + left form / right output panels). */ + +.tg-generator { + margin: 16px 0 24px; + border: 1px solid #ddd; + border-radius: 10px; + background: #fff; + overflow: hidden; + font-size: 0.78rem; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); +} + +/* Header (matches .cg-modal-header) */ +.tg-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + border-bottom: 1px solid #ddd; +} + +.tg-title { + font-weight: 600; + font-size: 0.9rem; +} + +.tg-reset { + background: none; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 0.72rem; + color: #777; + cursor: pointer; + padding: 4px 12px; + font-family: inherit; + transition: color 0.15s, border-color 0.15s; +} + +.tg-reset:hover { + color: #333; + border-color: #999; +} + +/* Body: left (form) + right (output), matches .cg-modal-body */ +.tg-body { + display: flex; + min-height: 0; +} + +.tg-left { + flex: 1; + border-right: 1px solid #ddd; + padding: 16px 18px; + min-width: 0; +} + +.tg-right { + flex: 1; + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +/* One output per block: label on its own line, then value field + copy button */ +.tg-output-row { + display: flex; + flex-direction: column; + min-width: 0; +} + +.tg-output-line { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +/* Form field (matches .cg-field) */ +.tg-field > label { + display: block; + font-weight: 500; + margin-bottom: 4px; + font-size: 0.78rem; + color: #555; +} + +.tg-field input[type="text"] { + width: 100%; + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 0.78rem; + font-family: inherit; + box-sizing: border-box; + background: #fff; +} + +.tg-field input[type="text"]:focus { + border-color: var(--md-primary-fg-color); + outline: none; + box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15); +} + +.tg-note { + margin-top: 10px; + font-size: 0.72rem; + color: #999; + line-height: 1.5; +} + +.tg-note code { + font-size: 0.72rem; + padding: 1px 4px; +} + +.tg-output-label { + margin-bottom: 4px; + white-space: nowrap; + font-weight: 500; + font-size: 0.78rem; + color: #555; +} + +/* Copy button (matches .cg-btn-copy) */ +.tg-btn-copy { + background: none; + color: #777; + border: none; + padding: 2px 4px; + cursor: pointer; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s; +} + +.tg-btn-copy:hover { + color: #333; +} + +/* Output block (matches .cg-output-wrap pre). Scoped under .tg-generator so the margin + reset beats the theme's .md-typeset pre rule, which otherwise adds a stray top margin. */ +.tg-generator .tg-output { + flex: 1; + min-width: 0; + margin: 0; + padding: 6px 9px; + background: #f5f5f5; + color: var(--md-default-fg-color); + border: 1px solid #ddd; + border-radius: 6px; + overflow-x: auto; + font-family: var(--md-code-font-family, monospace); + font-size: 0.72rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: anywhere; +} + +/* Dark mode */ +body[data-md-color-scheme="slate"] .tg-generator { + background: #1e1e2e; + border-color: #444; +} + +body[data-md-color-scheme="slate"] .tg-header { + border-bottom-color: #444; +} + +body[data-md-color-scheme="slate"] .tg-title { + color: #ddd; +} + +body[data-md-color-scheme="slate"] .tg-reset { + border-color: #555; + color: #888; +} + +body[data-md-color-scheme="slate"] .tg-reset:hover { + border-color: #888; + color: #ddd; +} + +body[data-md-color-scheme="slate"] .tg-left { + border-right-color: #444; +} + +body[data-md-color-scheme="slate"] .tg-field > label, +body[data-md-color-scheme="slate"] .tg-output-label { + color: #aaa; +} + +body[data-md-color-scheme="slate"] .tg-btn-copy { + color: #888; +} + +body[data-md-color-scheme="slate"] .tg-btn-copy:hover { + color: #bbb; +} + +body[data-md-color-scheme="slate"] .tg-field input[type="text"] { + background: #2a2a3a; + border-color: #555; + color: #ddd; +} + +body[data-md-color-scheme="slate"] .tg-note { + color: #777; +} + +body[data-md-color-scheme="slate"] .tg-output { + background: #161620; + border-color: #444; +} + +/* Responsive: stack panels like the config generator does on mobile */ +@media (max-width: 700px) { + .tg-body { + flex-direction: column; + } + + .tg-left { + border-right: none; + border-bottom: 1px solid #ddd; + } + + body[data-md-color-scheme="slate"] .tg-left { + border-bottom-color: #444; + } +} diff --git a/docs/static/js/topic-generator.js b/docs/static/js/topic-generator.js new file mode 100644 index 00000000..a6739891 --- /dev/null +++ b/docs/static/js/topic-generator.js @@ -0,0 +1,121 @@ +// Topic name generator for the ntfy docs +// +// A tiny helper that lives on the "Publishing" page. The user types a memorable +// prefix (e.g. "backups"), and the widget appends a random, hard-to-guess suffix +// (e.g. "backups-x7Kp2mQ9"). The result is a valid, unguessable topic name. +// +// Topic names on the server must match ^[-_A-Za-z0-9]{1,64}$ (see server.go), so as +// the user types we strip anything that isn't allowed (spaces, slashes, punctuation, +// emoji, ...) live and cap the whole thing at 64 characters. The random suffix is +// generated once on load and can be re-rolled with the "Regenerate suffix" button. +(function () { + // Allowed topic characters per the server regex ^[-_A-Za-z0-9]{1,64}$ + const ALLOWED = /[^-_A-Za-z0-9]/g; + const MAX_LEN = 64; + + // Suffix alphabet: full base62 (letters + digits). We deliberately keep look-alikes + // (0/O, l/1) for maximum entropy -- this is a generated suffix, not something typed by + // hand. Hyphen/underscore are excluded so the "-" separator stays visually clear. + const SUFFIX_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const SUFFIX_LEN = 10; + + // randomSuffix returns a cryptographically random string from SUFFIX_ALPHABET. + // It uses rejection sampling to avoid the modulo bias that a plain `byte % 62` would + // introduce (256 is not a multiple of 62), keeping every character equally likely. + function randomSuffix() { + const n = SUFFIX_ALPHABET.length; + const limit = Math.floor(256 / n) * n; // largest multiple of n that fits in a byte + const buf = new Uint8Array(1); + let out = ""; + while (out.length < SUFFIX_LEN) { + crypto.getRandomValues(buf); + if (buf[0] < limit) { + out += SUFFIX_ALPHABET[buf[0] % n]; + } + } + return out; + } + + // sanitize strips everything that isn't a valid topic character. + function sanitize(value) { + return value.replace(ALLOWED, ""); + } + + function initTopicGenerator() { + const root = document.getElementById("tg-widget"); + if (!root) return; + + const input = root.querySelector("#tg-input"); + const outputName = root.querySelector("#tg-output-name"); + const outputUrl = root.querySelector("#tg-output-url"); + const reroll = root.querySelector("#tg-reroll"); + + let suffix = randomSuffix(); + + // update recomputes the live preview from the (sanitized) input + current suffix. + function update() { + // Sanitize in place so the user sees disallowed characters disappear as they type. + const cleaned = sanitize(input.value); + if (cleaned !== input.value) { + const pos = input.selectionStart - (input.value.length - cleaned.length); + // Reassigning .value and setSelectionRange make the browser scroll the field into + // view (there is no preventScroll option for setSelectionRange), which jumps the + // whole page. Capture the scroll position and restore it afterwards. + const scrollX = window.scrollX; + const scrollY = window.scrollY; + input.value = cleaned; + // Best-effort caret restore so removing a bad char doesn't jump the cursor to the end. + try { input.setSelectionRange(pos, pos); } catch { /* ignore */ } + window.scrollTo(scrollX, scrollY); + } + + // Compose "-", capped at the 64-char topic limit. With no prefix, + // fall back to just the random suffix so the output is always a valid topic. + let topic; + if (cleaned === "") { + topic = suffix; + } else { + const maxPrefix = MAX_LEN - suffix.length - 1; // room for "-" + suffix + const prefix = cleaned.slice(0, Math.max(0, maxPrefix)); + topic = prefix === "" ? suffix : prefix + "-" + suffix; + } + + outputName.textContent = topic; + outputUrl.textContent = "https://ntfy.sh/" + topic; + } + + input.addEventListener("input", update); + reroll.addEventListener("click", function () { + suffix = randomSuffix(); + update(); + input.focus(); + }); + + // Copy buttons: copy the target output and briefly swap the clipboard icon for a checkmark, + // mirroring the config generator's copy button behavior. + const copyIcon = ""; + const checkIcon = ""; + root.querySelectorAll(".tg-btn-copy").forEach(function (btn) { + btn.addEventListener("click", function () { + const target = root.querySelector("#" + btn.dataset.copy); + if (!target || !target.textContent) return; + navigator.clipboard.writeText(target.textContent).then(function () { + btn.innerHTML = checkIcon; + btn.style.color = "var(--md-primary-fg-color)"; + setTimeout(function () { + btn.innerHTML = copyIcon; + btn.style.color = ""; + }, 2000); + }); + }); + }); + + update(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initTopicGenerator); + } else { + initTopicGenerator(); + } +})(); diff --git a/mkdocs.yml b/mkdocs.yml index 76b5c1f9..5bb53731 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -44,9 +44,11 @@ extra_javascript: - static/js/extra.js - static/js/bcrypt.js - static/js/config-generator.js + - static/js/topic-generator.js extra_css: - static/css/extra.css - static/css/config-generator.css + - static/css/topic-generator.css markdown_extensions: - admonition